React State Management: Context, Redux, and Zustand
March 13, 2025 · 9 min read
React, Redux, Zustand, State Management, Interview Prep
Introduction
As React applications grow, managing state that needs to be shared across many components becomes challenging. This article explores three approaches: React's built-in Context API, Redux Toolkit for complex state logic, and Zustand for a lightweight alternative.
The Problem: Prop Drilling
// Without global state - prop drilling nightmare
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
return (
<Layout user={user} theme={theme}>
<Dashboard user={user} theme={theme}>
<Sidebar user={user} theme={theme}>
<UserProfile user={user} onUserUpdate={setUser} theme={theme} />
</Sidebar>
</Dashboard>
</Layout>
);
}
// Every component in the tree needs to pass props down!
Solution 1: React Context API
Context provides a way to pass data through the component tree without manually passing props at every level.
import { createContext, useContext, useState, useCallback } from 'react';
// Create typed context
const AuthContext = createContext(null);
// Provider component
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const login = useCallback(async (email, password) => {
setIsLoading(true);
try {
const response = await authApi.login(email, password);
setUser(response.user);
localStorage.setItem('token', response.token);
} finally {
setIsLoading(false);
}
}, []);
const logout = useCallback(async () => {
await authApi.logout();
setUser(null);
localStorage.removeItem('token');
}, []);
const value = {
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// Custom hook with type safety
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Context Composition
function AppProviders({ children }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
}
Performance: Splitting Context
// Problem: Single context causes unnecessary re-renders
const UserContext = createContext(null);
// Solution: Split into separate contexts
const UserDataContext = createContext(null);
const UserPreferencesContext = createContext(null);
// Components only re-render when their specific data changes
function UserAvatar() {
const user = useContext(UserDataContext);
return <Avatar src={user?.avatarUrl} />;
}
function ThemeToggle() {
const { preferences, updatePreferences } = useContext(UserPreferencesContext);
return (
<Toggle
checked={preferences.darkMode}
onChange={() => updatePreferences({ darkMode: !preferences.darkMode })}
/>
);
}
When to Use Context
Context is ideal for low-frequency updates: themes, auth state, locale. For frequent updates like form state or real-time data, consider Zustand or Redux.
Solution 2: Redux Toolkit
Redux Toolkit is the official, opinionated way to write Redux. It simplifies store setup and reduces boilerplate.
Store Setup
import { configureStore } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import cartReducer from './slices/cartSlice';
import uiReducer from './slices/uiSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
ui: uiReducer,
},
});
// TypeScript types
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Typed hooks
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Creating Slices
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// Async thunk for API calls
export const fetchProducts = createAsyncThunk(
'products/fetchProducts',
async (_, { rejectWithValue }) => {
try {
return await productsApi.getAll();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const productsSlice = createSlice({
name: 'products',
initialState: {
items: [],
status: 'idle',
error: null,
},
reducers: {
// Synchronous actions with Immer
addProduct: (state, action) => {
state.items.push(action.payload);
},
removeProduct: (state, action) => {
state.items = state.items.filter(p => p.id !== action.payload);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
});
},
});
export const { addProduct, removeProduct } = productsSlice.actions;
export default productsSlice.reducer;
Memoized Selectors
import { createSelector } from '@reduxjs/toolkit';
const selectProducts = (state) => state.products.items;
const selectFilter = (state) => state.ui.productFilter;
// Memoized selector - only recalculates when inputs change
export const selectFilteredProducts = createSelector(
[selectProducts, selectFilter],
(products, filter) => {
if (!filter) return products;
return products.filter(p => p.category === filter);
}
);
// Usage in component
function ProductList() {
const products = useAppSelector(selectFilteredProducts);
// Only re-renders when filtered products change
}
Solution 3: Zustand
Zustand is a small, fast state management library with a simpler API than Redux.
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
const useCartStore = create()(
devtools(
persist(
(set, get) => ({
items: [],
totalItems: 0,
addItem: (product) => set((state) => {
const existing = state.items.find(i => i.id === product.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
),
totalItems: state.totalItems + 1,
};
}
return {
items: [...state.items, { ...product, quantity: 1 }],
totalItems: state.totalItems + 1,
};
}),
removeItem: (productId) => set((state) => {
const item = state.items.find(i => i.id === productId);
return {
items: state.items.filter(i => i.id !== productId),
totalItems: state.totalItems - (item?.quantity || 0),
};
}),
clearCart: () => set({ items: [], totalItems: 0 }),
// Computed value using get()
getTotal: () => {
return get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
}),
{ name: 'cart-storage' } // localStorage key
),
{ name: 'CartStore' } // DevTools name
)
);
Efficient Subscriptions
// Subscribe to specific state slices
function CartCount() {
// Only re-renders when totalItems changes
const totalItems = useCartStore((state) => state.totalItems);
return <span className="badge">{totalItems}</span>;
}
function CartTotal() {
// Subscribe to computed value
const total = useCartStore((state) => state.getTotal());
return <span>${total.toFixed(2)}</span>;
}
// Multiple selectors with shallow comparison
import { shallow } from 'zustand/shallow';
function CartActions() {
const { addItem, removeItem, clearCart } = useCartStore(
(state) => ({
addItem: state.addItem,
removeItem: state.removeItem,
clearCart: state.clearCart,
}),
shallow
);
}
Comparison: When to Use What
| Feature | Context | Redux Toolkit | Zustand |
|---|---|---|---|
| Bundle size | 0 (built-in) | ~12KB | ~2KB |
| Boilerplate | Low | Medium | Low |
| DevTools | React DevTools | Redux DevTools | Redux DevTools |
| Learning curve | Low | Medium | Low |
| Middleware | No | Yes | Yes |
| Persistence | Manual | Manual/RTK Query | Built-in |
| Best for | Low-frequency state | Complex state logic | Medium apps |
Hybrid Approach
Many production apps combine approaches: Context for auth/theme (low-frequency), React Query for server state (caching), and Zustand for complex UI state (modals, wizards, filters).
You Might Not Need a State Library
When I started Glucoplate, I assumed I'd need Redux or Zustand. Turns out, custom hooks with useState handled most cases just fine. React Query eliminated the need for global state for server data. The lesson: don't add state management libraries preemptively. Start simple, and only add complexity when you hit actual pain points. Many apps never need more than React's built-in tools.
Interview Questions
Q1: When would you choose Context over Redux or Zustand?
Answer: Context is ideal for:
- Low-frequency updates - Theme, locale, auth that change rarely
- Simple state shapes - A few values, not deeply nested
- Dependency injection - Providing services to the tree
- Zero dependencies - Built into React
Choose Redux/Zustand when you have frequent updates, complex state logic, or need middleware.
Q2: Explain createSelector and why it matters.
Answer: createSelector creates memoized selectors that only recompute when inputs change:
// Without memoization - runs every render
const filteredProducts = products.filter(p => p.category === category);
// With createSelector - cached until inputs change
const selectFilteredProducts = createSelector(
[selectProducts, selectCategory],
(products, category) => products.filter(p => p.category === category)
);
This prevents unnecessary recalculations and re-renders.
Q3: How does Zustand achieve better performance than Context?
Answer:
- Selector subscriptions - Components only subscribe to specific slices
- External store - State lives outside React, avoiding reconciliation
- Shallow comparison - Prevents unnecessary re-renders
- No Provider wrapper - No cascading updates
// Context - entire subtree re-renders on any change
const { user, theme } = useContext(AppContext);
// Zustand - only re-renders when user changes
const user = useStore((state) => state.user);
Q4: What's the prop drilling problem and how do you solve it?
Answer: Prop drilling is passing props through many component levels to reach deeply nested components. Problems:
- Maintenance burden
- Component coupling
- Refactoring difficulty
Solutions:
- Context API for global state
- Redux/Zustand stores
- Component composition (restructure with children/render props)
Common Mistakes
1. Putting Everything in Global State
// ❌ Form state doesn't need global store
const useStore = create((set) => ({
loginEmail: '',
loginPassword: '',
setLoginEmail: (email) => set({ loginEmail: email }),
}));
// ✅ Form state is local
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const login = useAuthStore((state) => state.login);
}
2. Not Memoizing Selectors
// ❌ Creates new object every render
const data = useStore((state) => ({
items: state.items,
total: state.total,
}));
// ✅ Use shallow comparison
const { items, total } = useStore(
(state) => ({ items: state.items, total: state.total }),
shallow
);
3. Mutating State Directly
// ❌ Direct mutation (except in Immer)
function reducer(state, action) {
state.items.push(action.payload);
return state;
}
// ✅ Return new state
function reducer(state, action) {
return {
...state,
items: [...state.items, action.payload],
};
}
Summary
- Context API is built into React, best for low-frequency updates
- Redux Toolkit provides structure for complex applications
- Zustand offers simpler API with excellent performance
- Use React Query for server state, global stores for client state
- Split contexts and use selectors to prevent unnecessary re-renders
- Not all state needs to be global - keep form and UI state local
Next up: Performance Optimization with memo, useMemo, useCallback, and code splitting.
Part 7 of the React Developer Reference series.