React State Management: Context vs Redux vs Zustand
Compare React state management solutions: Context API, Redux Toolkit, and Zustand. Learn when to use each with practical examples and trade-offs.
What you'll learn
- ✓How Context API works and when it falls short
- ✓Setting up Redux Toolkit for scalable state
- ✓Using Zustand for simple, performant global state
- ✓Choosing the right solution for your project
Prerequisites
- •React hooks (useState, useContext, useReducer)
- •Basic understanding of state management concepts
Every React app needs to decide how to share state between components. React gives you Context API built-in, but the ecosystem offers alternatives like Redux Toolkit and Zustand. Each has different trade-offs around complexity, performance, and developer experience.
This guide compares all three with the same feature built in each, so you can see the real differences.
The Problem: Shared State
Consider a shopping cart. Multiple components need access to the cart: the navbar badge, the cart drawer, product cards, and the checkout page. Passing props through every level is not practical.
You need a way to make cart state accessible anywhere in your component tree. Let us build it with each approach.
Context API
Context is built into React. No extra packages needed.
import { createContext, useContext, useReducer, type ReactNode } from 'react';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
total: number;
}
type CartAction =
| { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR' };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(i => i.id === action.payload.id);
const items = existing
? state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: i.quantity + 1 }
: i
)
: [...state.items, { ...action.payload, quantity: 1 }];
return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) };
}
case 'REMOVE_ITEM': {
const items = state.items.filter(i => i.id !== action.payload);
return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) };
}
case 'UPDATE_QUANTITY': {
const items = state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: action.payload.quantity }
: i
);
return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) };
}
case 'CLEAR':
return { items: [], total: 0 };
default:
return state;
}
}
const CartContext = createContext<{
state: CartState;
dispatch: React.Dispatch<CartAction>;
} | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}
Using it in components:
function CartBadge() {
const { state } = useCart();
const itemCount = state.items.reduce((sum, item) => sum + item.quantity, 0);
return <span className="badge">{itemCount}</span>;
}
function AddToCartButton({ product }: { product: Product }) {
const { dispatch } = useCart();
return (
<button onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}>
Add to Cart
</button>
);
}
Context API strengths: No dependencies, simple for small state, built into React.
Context API weaknesses: Every consumer re-renders when any part of the context value changes. If your cart has 20 items and you update one quantity, every component using useCart() re-renders, even CartBadge which only needs the count.
Redux Toolkit
Redux Toolkit is the modern way to use Redux. It eliminates the boilerplate that made old Redux painful.
npm install @reduxjs/toolkit react-redux
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
}
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] } as CartState,
reducers: {
addItem(state, action: PayloadAction<Omit<CartItem, 'quantity'>>) {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removeItem(state, action: PayloadAction<string>) {
state.items = state.items.filter(i => i.id !== action.payload);
},
updateQuantity(state, action: PayloadAction<{ id: string; quantity: number }>) {
const item = state.items.find(i => i.id === action.payload.id);
if (item) {
item.quantity = action.payload.quantity;
}
},
clearCart(state) {
state.items = [];
},
},
});
export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
export const store = configureStore({
reducer: {
cart: cartSlice.reducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Create typed hooks:
import { useSelector, useDispatch, type TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Using it in components:
function CartBadge() {
// Only re-renders when itemCount changes
const itemCount = useAppSelector(state =>
state.cart.items.reduce((sum, item) => sum + item.quantity, 0)
);
return <span className="badge">{itemCount}</span>;
}
function AddToCartButton({ product }: { product: Product }) {
const dispatch = useAppDispatch();
return (
<button onClick={() => dispatch(addItem(product))}>
Add to Cart
</button>
);
}
Redux strengths: Fine-grained subscriptions (components only re-render when their selected data changes), excellent DevTools, middleware for side effects, large ecosystem.
Redux weaknesses: More boilerplate than alternatives, steeper learning curve, separate package needed.
Zustand
Zustand takes a minimalist approach. No providers, no boilerplate, just a store:
npm install zustand
import { create } from 'zustand';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set(state => {
const existing = state.items.find(i => i.id === item.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set(state => ({
items: state.items.filter(i => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set(state => ({
items: state.items.map(i =>
i.id === id ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [] }),
total: () => {
const { items } = get();
return items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
}));
Using it in components:
function CartBadge() {
const itemCount = useCartStore(state =>
state.items.reduce((sum, item) => sum + item.quantity, 0)
);
return <span className="badge">{itemCount}</span>;
}
function AddToCartButton({ product }: { product: Product }) {
const addItem = useCartStore(state => state.addItem);
return (
<button onClick={() => addItem(product)}>
Add to Cart
</button>
);
}
Notice there is no Provider wrapper needed. Zustand stores work outside the React tree.
Zustand strengths: Minimal API, no provider needed, fine-grained subscriptions, works outside React, tiny bundle size (about 1KB).
Zustand weaknesses: Smaller ecosystem than Redux, no built-in DevTools middleware (though it has a devtools middleware you can add), less structured for very large apps.
Side-by-Side Comparison
Here is the same feature in each approach. Notice the differences in verbosity:
| Feature | Context | Redux Toolkit | Zustand |
|---|---|---|---|
| Setup code | ~60 lines | ~50 lines | ~40 lines |
| Provider needed | Yes | Yes | No |
| Re-render optimization | Manual splitting | Built-in selectors | Built-in selectors |
| DevTools | React DevTools | Redux DevTools | Optional middleware |
| Bundle size | 0 KB | ~11 KB | ~1 KB |
| Learning curve | Low | Medium | Low |
| Async actions | Custom | createAsyncThunk | Built-in |
| Middleware | None | Built-in | Plugin system |
When to Use Each
Use Context API when:
- State is simple (theme, locale, auth status)
- Few components consume the state
- You want zero dependencies
- The state does not change frequently
Use Redux Toolkit when:
- You have complex state with many actions
- You need middleware for side effects
- Multiple developers need consistent patterns
- You want time-travel debugging
- The app is large with many state slices
Use Zustand when:
- You want simple global state with good performance
- You need state accessible outside React components
- You want minimal bundle size
- You are building a small to medium app
- You want quick setup without boilerplate
Mixing Approaches
You do not have to pick just one. Many apps combine them:
// Context for theme and locale (rarely changes, few consumers)
<ThemeProvider>
<LocaleProvider>
{/* Zustand for cart, UI state (changes often, many consumers) */}
<App />
</LocaleProvider>
</ThemeProvider>
Use Context for dependency injection (passing services, configuration) and a dedicated state library for application state that changes frequently.
Wrapping Up
Context API works well for simple, infrequently-changing state. Redux Toolkit provides structure and tools for complex applications with many developers. Zustand offers the best balance of simplicity and performance for most projects. Start with the simplest option that meets your needs and migrate up only when you hit real limitations. The best state management solution is the one your team can understand and maintain.
Related articles
- React Zustand for Lightweight State Management in React
Learn Zustand, the minimal React state management library with no boilerplate, built-in selectors, and a hook-based API.
- React React Context vs Redux: When to Use Which
A practical comparison of React Context and Redux: rendering model, performance, devtools, and concrete heuristics for picking the right tool.
- React React State Management: Zustand vs Redux vs Context Compared
Compare Zustand, Redux Toolkit, and React Context for state management. Learn when each shines, their trade-offs, and how to pick the right tool.
- React useReducer in React and Why It Beats useState Sometimes
Understand React's useReducer hook, its signature, action shape, when to migrate from useState, and how to build a small state machine pattern for predictable UI logic.