React State Management: Context, Redux, and Zustand Compared
A decision guide comparing useState, Context, Redux Toolkit, and Zustand — covering re-render trade-offs, when each tool fits, and the one Context surprise that trips up most developers.

Most React developers hit the same crossroads eventually. A component three levels deep needs some state from the top of the tree, and suddenly prop drilling doesn’t feel clean. The instinct? Reach for a state management library. Probably Redux.
But the real question isn’t whether you need a library — it’s which problem you’re actually solving. Context, Redux Toolkit, and Zustand all manage state, but they solve fundamentally different problems. Treating them as interchangeable leads to over-engineered todo apps and under-scaled dashboards.
I’m going to walk through what each tool actually does, where each one breaks down, and show you one surprising thing about Context + useReducer that catches developers off guard every time.
Series: This is part of a series on React. The foundations are in React Fundamentals: Components, Props, and State. The hooks that power most interactive UI are covered in React Hooks You’ll Actually Use Every Day. Component design patterns are in Building Reusable React Components That Last.
On this page
- useState and prop drilling are not the enemy
- Context is for slow-changing global state
- Context + useReducer: the surprise inside
- Redux Toolkit: when the structure pays off
- Zustand: subscribe to only what you need
- Which tool for which situation
- The anti-pattern worth naming
useState and prop drilling are not the enemy
The most common mistake in React codebases is treating prop drilling as a problem that needs solving immediately. Prop drilling only becomes a real cost when you’re threading the same value through three or four intermediate components that don’t use it — only pass it down.
For small trees — a form, a modal, a card with a few nested components — useState in the parent and props in the children is the right choice. No provider to configure, no extra concept to explain to a new teammate, and you can trace the data flow by reading the JSX.
function SearchBar({ onSearch }) {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
onSearch(e.target.value);
}}
/>
);
}
The actual signal to look for: when you find yourself passing a prop through a component that never uses it — it only passes it to its children — that’s the point where a different approach makes sense. Not before.
Context is for slow-changing global state
Context is React’s built-in broadcast mechanism. You put a value near the top of the tree, and any component below can read it without prop threading. The right use cases are things that rarely change: the current theme, the authenticated user object, the active locale.
const AuthContext = createContext(null);
function App() {
const [user, setUser] = useState(null);
return (
<AuthContext value={user}>
<Router />
</AuthContext>
);
}
function ProfileMenu() {
const user = useContext(AuthContext);
return user ? <span>{user.name}</span> : null;
}
This works well because user changes rarely — a login or logout, maybe once per session. According to the React documentation, “React automatically re-renders all the children that use a particular context starting from the provider that receives a different value.” For theme and auth that re-render cost is nearly zero.
For a form where every keystroke updates state, or a data feed that updates every few seconds, that same mechanism becomes expensive. Every component subscribed to the context re-renders whenever the provider’s value changes — regardless of whether that specific component cares about the changed portion. Context is not a solution for high-frequency updates.
Good fits for Context: theme toggle, user authentication, locale/language, feature flags. Poor fits: form field values, animation state, frequently polled server data.
Context + useReducer: the surprise inside
Here’s the thing that catches people off guard. The React documentation describes a pattern called “scaling up with a reducer and context” — combining useReducer with two Context providers, one for state and one for dispatch, to create something that looks a lot like a mini Redux store.
const StoreContext = createContext(null);
const DispatchContext = createContext(null);
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StoreContext value={state}>
<DispatchContext value={dispatch}>
{children}
</DispatchContext>
</StoreContext>
);
}
This is a legitimate pattern — and it’s useful for moderately complex state. But it carries the same re-render behavior as any other Context value. When dispatch triggers a state update, the new state object flows through StoreContext, and every component reading from StoreContext re-renders.
There’s no equivalent to a selector here. You can’t say “only re-render this component when state.user changes.” If any part of the state changes, every consumer re-renders. For a small app with a handful of consuming components, that’s fine. For a medium-sized app where many components subscribe to a shared store, it starts to matter.
The pattern isn’t wrong — it just doesn’t solve the performance problem that people sometimes assume it solves. This is the part where teams end up adding useMemo and useCallback everywhere to compensate, which adds complexity without addressing the root issue.
Redux Toolkit: when the structure pays off
Redux earned a reputation for boilerplate, and that reputation was earned by the older patterns that required separately wiring action types, action creators, and reducers. Redux Toolkit is the modern answer — the Redux team’s own solution.
createSlice handles action creators and reducer logic together. The store includes Immer by default (so you write “mutating” reducer code that actually performs immutable updates), redux-thunk for async actions, and integration with the Redux DevTools Extension.
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], total: 0 },
reducers: {
addItem: (state, action) => {
// Immer lets you write this as a direct mutation
state.items.push(action.payload);
state.total += action.payload.price;
},
removeItem: (state, action) => {
state.items = state.items.filter((item) => item.id !== action.payload);
},
},
});
export const { addItem, removeItem } = cartSlice.actions;
Redux Toolkit pays off when:
- Your app has complex, interconnected state transitions
- Multiple developers work on the same state domain
- You need time-travel debugging (replaying actions to reproduce a specific bug)
- A large team benefits from enforced patterns and strict data-flow discipline
The overhead is real — store configuration, Provider wrapping, selectors, connecting components to the store. That overhead is worth it at scale. On a three-screen app, it’s friction without payoff.
Zustand: subscribe to only what you need
Zustand takes a different approach to the re-render problem. Instead of a Provider you wrap your tree in, it creates a store as a hook. Components subscribe with a selector — a function that picks exactly what they need.
import { create } from 'zustand';
const useStore = create((set) => ({
user: null,
theme: 'light',
cart: [],
setTheme: (theme) => set({ theme }),
setUser: (user) => set({ user }),
}));
// Only re-renders when `theme` changes
function ThemeToggle() {
const theme = useStore((state) => state.theme);
const setTheme = useStore((state) => state.setTheme);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme}
</button>
);
}
// Only re-renders when `user` changes
function UserGreeting() {
const user = useStore((state) => state.user);
return user ? <span>Hi, {user.name}</span> : null;
}
Both components share the same store. They re-render independently based on what they subscribe to. The Zustand README explicitly notes this advantage over Context: it “renders components only on changes.”
Zustand also works outside the React tree. You can read and update state in event listeners, WebSocket callbacks, or a regular JavaScript module — without a hook or component in scope. This flexibility matters for medium-sized apps where state needs to be touched from different layers of the codebase.
The API is small. No provider, no action types, no connect. For most medium-complexity apps, it handles the selector subscription model that Context + useReducer can’t provide — without taking on the full weight of Redux.
Which tool for which situation
The right choice depends on your actual app, not a preference. Here’s a decision table that factors in app size, update frequency, and team context:
| Situation | Recommended |
|---|---|
| Small app, 2–5 components sharing state | useState + props |
| Slow global state (theme, auth, locale) | Context API |
| Medium app with selective subscriptions needed | Zustand |
| Large app, complex logic, many contributors | Redux Toolkit |
| High-frequency updates (animations, per-keystroke) | Local state — keep it close |
A few things that don’t fit neatly in a row:
Context and Zustand aren’t mutually exclusive. Auth in Context (changes once per session) alongside Zustand for interactive state (changes frequently) is a reasonable split. When a Context-based state starts to feel slow or awkward, Zustand is a low-friction replacement that doesn’t require rewriting your dispatch logic.
The anti-pattern worth naming
The most common state management mistake in React projects isn’t picking the wrong library — it’s picking one too early. A new project with three screens and one user type gets a Redux store configured on day one because “we’ll need it eventually.”
Eventually rarely arrives on the original timeline, and when it does, the requirements have changed. The result is a store that’s mostly empty, selectors for two values, and unnecessary friction for every future contributor who has to learn the store before they can change a label.
Start with useState and props. Add Context when prop drilling through four or five layers genuinely hurts. Switch to Zustand when Context re-renders start mattering. Bring in Redux Toolkit when the team size and codebase complexity make structured patterns worth the setup cost.
Choosing a tool to signal seriousness rather than to solve a present problem creates work, not value.
Let the problem choose the tool
The shift worth making: stop thinking about state management as a one-time decision at project setup and start treating it as a decision you revisit as the app grows.
useState gets you further than most developers expect. Context handles the cases where props become a burden, as long as the value changes slowly. When Context re-renders start mattering, Zustand’s selector model solves it without the overhead of Redux. Redux Toolkit earns its place in large, collaborative apps where enforced data-flow discipline prevents whole categories of bugs.
The next time you need global state, the first question to ask isn’t “Redux or Zustand?” — it’s “how often does this value change, and how many components actually need it?” The answer usually makes the decision obvious.