State Management
Every React app has data that changes â a counter going up, a user logging in, a shopping cart filling up. State is data that changes over time and affects what your app shows on screen. The question is: where should that data live, and how should you manage it?
Local State vs Global State
Think of it like notes in an office:
- Local state is a sticky note on your own monitor â only you need it
- Global state is a whiteboard in the hallway â everyone can see and use it
Local State with useState
For data that only one component needs, useState is perfect:
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Add One</button>
</div>
);
}The count lives inside Counter. No other component knows about it, and that's fine â it's a sticky note on this component's monitor.
When Local State Isn't Enough
Sometimes multiple components need the same data. Imagine a shopping app where the Header shows the cart count and the CartPage shows the cart items â they both need access to the cart.
You could pass props down through every component (prop drilling), but that gets ugly fast:
// Prop drilling â passing cart through components that don't even use it
<App cart={cart}>
<Layout cart={cart}>
<Header cart={cart}>
<CartIcon count={cart.length} />
</Header>
</Layout>
</App>Think of it like passing a note through 5 people just to get it to the person sitting across the room. That's where global state solutions come in.
Context API â The Built-In Solution
When to Use Context
Context works best for data that rarely changes and is needed by many components â like the current theme, the logged-in user, or the selected language.
Think of it like a radio station: you broadcast the data once, and any component can tune in.
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext("light");
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme((t) => (t === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
return useContext(ThemeContext);
}
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header className={theme}>
<button onClick={toggleTheme}>Switch to {theme === "light" ? "dark" : "light"}</button>
</header>
);
}
function App() {
return (
<ThemeProvider>
<Header />
<MainContent />
</ThemeProvider>
);
}The Re-Render Trap in Context (Important!)
Here's Context's biggest gotcha: when the context value changes, every component consuming that context re-renders â even if it only uses a tiny piece of the data.
Think of it like a group chat. When anyone sends a message, everyone's phone buzzes â even if the message isn't for them.
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState({ name: "Alice" });
const [theme, setTheme] = useState("light");
const [notifications, setNotifications] = useState(0);
return (
<AppContext.Provider value={{ user, theme, notifications, setTheme, setNotifications }}>
{children}
</AppContext.Provider>
);
}
function UserGreeting() {
const { user } = useContext(AppContext);
console.log("UserGreeting re-rendered!");
return <p>Hello, {user.name}</p>;
}
function ThemeToggle() {
const { theme, setTheme } = useContext(AppContext);
console.log("ThemeToggle re-rendered!");
return <button onClick={() => setTheme("dark")}>Toggle Theme</button>;
}When notifications updates, both UserGreeting and ThemeToggle re-render â even though neither of them uses notifications. That's wasted work.
The fix: split your contexts by how often they change.
const UserContext = createContext(null);
const ThemeContext = createContext("light");
const NotificationContext = createContext(0);
function App() {
return (
<UserProvider>
<ThemeProvider>
<NotificationProvider>
<MainApp />
</NotificationProvider>
</ThemeProvider>
</UserProvider>
);
}Now updating notifications only re-renders components that consume NotificationContext. The user greeting and theme toggle are untouched.
Common Mistake: Stuffing everything into one giant Context. This turns every state update into a re-render storm. Split contexts by update frequency.
When to Use Redux
Redux is like a central database for your frontend. Every piece of state lives in one big store, and changes happen through a strict process: dispatch an action â reducer creates new state â components update.
Redux Makes Sense When:
- Your app has complex state logic (undo/redo, multi-step workflows)
- Many components need to read and write shared state
- You want dev tools for time-travel debugging
- You need middleware for side effects (logging, analytics, async flows)
Redux Is Overkill When:
- You have a simple app with a few pieces of shared state
- Most of your state is server data (use React Query instead)
- You're building a small to medium project
Basic Redux Toolkit Example
import { configureStore, createSlice } from "@reduxjs/toolkit";
import { Provider, useSelector, useDispatch } from "react-redux";
const todoSlice = createSlice({
name: "todos",
initialState: [],
reducers: {
addTodo: (state, action) => {
state.push({ id: Date.now(), text: action.payload, done: false });
},
toggleTodo: (state, action) => {
const todo = state.find((t) => t.id === action.payload);
if (todo) todo.done = !todo.done;
},
},
});
const store = configureStore({ reducer: { todos: todoSlice.reducer } });
function TodoList() {
const todos = useSelector((state) => state.todos);
const dispatch = useDispatch();
return (
<div>
<button onClick={() => dispatch(todoSlice.actions.addTodo("New task"))}>
Add Todo
</button>
<ul>
{todos.map((todo) => (
<li
key={todo.id}
onClick={() => dispatch(todoSlice.actions.toggleTodo(todo.id))}
style={{ textDecoration: todo.done ? "line-through" : "none" }}
>
{todo.text}
</li>
))}
</ul>
</div>
);
}
function App() {
return (
<Provider store={store}>
<TodoList />
</Provider>
);
}Remember: Modern Redux (Redux Toolkit) is much simpler than the old Redux. If you've heard "Redux has too much boilerplate," that was true before 2020. Redux Toolkit fixed most of those problems.
Context + useReducer: A Lightweight Redux
If Redux feels too heavy but useState feels too simple, try Context + useReducer. It gives you Redux-like dispatch patterns without installing anything:
import { createContext, useContext, useReducer } from "react";
const CartContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case "ADD_ITEM":
return [...state, action.payload];
case "REMOVE_ITEM":
return state.filter((item) => item.id !== action.payload);
case "CLEAR_CART":
return [];
default:
return state;
}
}
function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, []);
return (
<CartContext.Provider value={{ cart, dispatch }}>
{children}
</CartContext.Provider>
);
}
function useCart() {
return useContext(CartContext);
}
function ProductCard({ product }) {
const { dispatch } = useCart();
return (
<div>
<h3>{product.name} â ${product.price}</h3>
<button onClick={() => dispatch({ type: "ADD_ITEM", payload: product })}>
Add to Cart
</button>
</div>
);
}
function CartSummary() {
const { cart, dispatch } = useCart();
return (
<div>
<p>Items in cart: {cart.length}</p>
<button onClick={() => dispatch({ type: "CLEAR_CART" })}>Clear</button>
</div>
);
}Think of it like a mini Redux living inside your React tree. You get structured actions and a reducer, but without the full Redux ecosystem. This works great for medium-complexity state that multiple components share.
Common Mistake: Using Context + useReducer for everything. Remember, Context still has the re-render problem. If your state updates frequently, this pattern will cause the same performance issues as plain Context.
External State Libraries
When Context gets painful and Redux feels heavy, external libraries offer a sweet middle ground.
Zustand â Simple and Tiny
Zustand (German for "state") is like a global useState â minimal API, no providers needed:
import { create } from "zustand";
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<div>
<p>{count}</p>
<button onClick={increment}>+1</button>
</div>
);
}The key advantage: components only re-render when the specific slice of state they read changes. If Counter only reads count, it won't re-render when something else in the store changes.
Jotai â Atomic State
Jotai takes a different approach: instead of one store, you create individual atoms (tiny pieces of state):
import { atom, useAtom } from "jotai";
const countAtom = atom(0);
const doubleCountAtom = atom((get) => get(countAtom) * 2);
function Counter() {
const [count, setCount] = useAtom(countAtom);
const [doubleCount] = useAtom(doubleCountAtom);
return (
<div>
<p>Count: {count}</p>
<p>Double: {doubleCount}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
);
}Think of it like building with LEGO blocks â each atom is an independent piece, and you can combine them however you want.
Quick Comparison
| Feature | Context | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|---|
| Setup effort | Minimal | Moderate | Minimal | Minimal |
| Re-render control | Poor (all consumers) | Good (selectors) | Great (selectors) | Great (atoms) |
| DevTools | No | Yes (excellent) | Yes (plugin) | Yes (plugin) |
| Learning curve | Low | Medium | Low | Low |
| Best for | Rarely-changing data | Complex apps | Most apps | Fine-grained state |
State Management Decision Guide
Not sure what to pick? Walk through this:
Is the data only needed in ONE component?
â useState
Is the data needed by a FEW nearby components?
â Lift state up to a shared parent, pass via props
Is the data from an API / server?
â React Query or SWR (not Redux or Context)
Is the data shared across MANY components but RARELY changes?
â Context API (theme, auth, locale)
Is the data shared across many components and FREQUENTLY changes?
â Zustand or Jotai
Do you need time-travel debugging, middleware, or complex workflows?
â Redux ToolkitRemember: Start simple.
useStatehandles more than you think. Only add global state management when you feel the pain of not having it. Premature optimization of state architecture is one of the biggest time sinks in React projects.
Interview Corner
What's the difference between local and global state? Local state (useState) lives inside one component. Global state is shared across multiple components that aren't directly connected via props.
What's wrong with putting everything in Context? When any value in a Context changes, ALL consumers re-render â even if they only use an unrelated part of the data. This causes unnecessary re-renders and performance issues.
When would you choose Redux over Context? When you need complex state logic (middleware, time-travel debugging), when many components read and write shared state frequently, or when you need a predictable, traceable state update pattern.
What is useReducer and when would you use it?
useReducer is like useState for complex state logic. Instead of calling setState directly, you dispatch actions to a reducer function. Use it when state updates depend on previous state or involve multiple sub-values.
How does Zustand avoid Context's re-render problem?
Zustand uses selectors â components subscribe to specific slices of state. If a component only reads count, it won't re-render when theme changes in the same store.