React State Management: useState vs Redux vs Zustand
By Pawan Singh · Jun 2026
The most common state management mistake isn't picking the wrong library — it's reaching for a library at all before useState and Context have actually run out of road. Here's when each genuinely earns its place.
useState: The Default, for Local Component State
const [isOpen, setIsOpen] = useState(false);
If a piece of state is only read and written by one component (and maybe passed to a couple of direct children as props), useState is the entire solution — no library needed. The instinct to add a state management library "because the app is getting bigger" without first checking whether the state in question actually needs to be shared broadly is the single most common source of unnecessary complexity in React codebases.
useReducer: For Related State That Changes Together
When several pieces of state update together in response to the same actions (a form with validation state, a multi-step wizard), a reducer centralizes the transition logic instead of scattering multiple setX calls across handlers:
function reducer(state, action) {
switch (action.type) {
case 'next_step': return { ...state, step: state.step + 1 };
case 'set_error': return { ...state, error: action.error };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, { step: 1, error: null });
This is still local component state — useReducer doesn't share anything across components by itself, it just organizes complex local state transitions more predictably than several independent useState calls.
Context: For Genuinely Global, Infrequently-Changing State
Context solves "avoid passing a prop through five layers of components that don't otherwise need it" (prop drilling) — it does not solve performance, and it's not a general state management replacement:
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Dashboard />
</ThemeContext.Provider>
);
}
Every component consuming a Context re-renders whenever its value changes, with no built-in way to subscribe to only part of it — fine for something that changes rarely (theme, authenticated user, locale), a real performance problem for state that updates frequently (form input, a live counter) and is consumed by many components.
Zustand: A Lightweight External Store
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));
function CartButton() {
const items = useCartStore((state) => state.items);
return <button>Cart ({items.length})</button>;
}
Zustand's key advantage over Context: components subscribe to a specific slice of the store (via the selector function passed to the hook) and only re-render when that slice changes — not on every store update. Minimal boilerplate, no provider wrapping required, and it works outside React components too (calling useCartStore.getState() directly), which makes it a good fit for shared client-side state that doesn't need Redux's stricter structure.
Redux (with Redux Toolkit): For Large, Complex, Team-Scale State
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
addItem: (state, action) => { state.items.push(action.payload); },
},
});
Modern Redux Toolkit is far less boilerplate-heavy than classic Redux, but it still brings more structure and ceremony than Zustand: actions, reducers, a single centralized store, and (its biggest practical advantage at scale) excellent devtools — a full, inspectable, time-travel-debuggable history of every state change. This matters more as a team and codebase grow large enough that "who changed this state, and why" becomes a real debugging need, not just a nice-to-have.
Server State Is a Different Problem Entirely
Data fetched from an API (a list of products, a user's profile) isn't really "client state" in the same sense — it has its own concerns (caching, background refetching, staleness, request deduplication) that useState/Redux/Zustand don't address out of the box. Tools like React Query (TanStack Query) or SWR are built specifically for this and should generally own server data, leaving useState/Context/Zustand/Redux for genuinely client-only state (UI state, form state, user preferences). Mixing the two — storing fetched API data in Redux and manually reimplementing caching/refetching — is a common source of unnecessary complexity.
A Practical Decision Ladder
- Is it local to one component (or a couple of direct children)?
useState/useReducer. Stop here if so. - Is it global but changes rarely (theme, auth, locale)? Context.
- Is it global, changes more often, and needs good performance without a lot of ceremony? Zustand.
- Is it large-scale, needs strict structure, or the team specifically wants Redux's devtools/time-travel debugging? Redux Toolkit.
- Is it data from a server? None of the above — use React Query or SWR instead.
Untangling a React app's state management, or architecting one from scratch? Get in touch — I work as a freelance React developer.