State Management with Context + useReducer
For complex global state management, combining Context API with useReducer provides a powerful pattern similar to Redux. This approach gives you predictable state updates and better organization for large applications.
Understanding the Context + Reducer Pattern
The useReducer hook manages complex state logic, while Context makes that state available throughout your component tree without prop drilling. This combination is perfect for medium-sized applications that need global state but don't require Redux.
When to Use Context + useReducer:
- Managing complex global state (auth, theme, cart)
- State that needs to be accessed by many components
- When you need predictable state updates
- Medium-sized apps that don't need Redux complexity
Creating a Global Store
Let's build a shopping cart system using Context and useReducer:
// contexts/CartContext.jsx
import { createContext, useReducer, useContext } from 'react';
// Action types
const CART_ACTIONS = {
ADD_ITEM: 'ADD_ITEM',
REMOVE_ITEM: 'REMOVE_ITEM',
UPDATE_QUANTITY: 'UPDATE_QUANTITY',
CLEAR_CART: 'CLEAR_CART',
APPLY_DISCOUNT: 'APPLY_DISCOUNT'
};
// Initial state
const initialState = {
items: [],
total: 0,
discount: 0,
itemCount: 0
};
// Reducer function
function cartReducer(state, action) {
switch (action.type) {
case CART_ACTIONS.ADD_ITEM: {
const existingItem = state.items.find(
item => item.id === action.payload.id
);
let newItems;
if (existingItem) {
newItems = state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
newItems = [...state.items, { ...action.payload, quantity: 1 }];
}
return {
...state,
items: newItems,
total: calculateTotal(newItems, state.discount),
itemCount: calculateItemCount(newItems)
};
}
case CART_ACTIONS.REMOVE_ITEM: {
const newItems = state.items.filter(
item => item.id !== action.payload
);
return {
...state,
items: newItems,
total: calculateTotal(newItems, state.discount),
itemCount: calculateItemCount(newItems)
};
}
case CART_ACTIONS.UPDATE_QUANTITY: {
const newItems = state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
).filter(item => item.quantity > 0);
return {
...state,
items: newItems,
total: calculateTotal(newItems, state.discount),
itemCount: calculateItemCount(newItems)
};
}
case CART_ACTIONS.CLEAR_CART:
return initialState;
case CART_ACTIONS.APPLY_DISCOUNT:
return {
...state,
discount: action.payload,
total: calculateTotal(state.items, action.payload)
};
default:
return state;
}
}
// Helper functions
function calculateTotal(items, discount) {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return subtotal * (1 - discount / 100);
}
function calculateItemCount(items) {
return items.reduce((sum, item) => sum + item.quantity, 0);
}
// Create context
const CartContext = createContext(null);
// Provider component
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
const value = {
state,
dispatch,
actions: CART_ACTIONS
};
return (
<CartContext.Provider value={value}>
{children}
</CartContext.Provider>
);
}
// Custom hook
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within CartProvider');
}
return context;
}
Creating Action Creators
Action creators make dispatching actions cleaner and provide better type safety:
// contexts/CartContext.jsx (continued)
// Action creators
export const cartActions = {
addItem: (item) => ({
type: CART_ACTIONS.ADD_ITEM,
payload: item
}),
removeItem: (itemId) => ({
type: CART_ACTIONS.REMOVE_ITEM,
payload: itemId
}),
updateQuantity: (itemId, quantity) => ({
type: CART_ACTIONS.UPDATE_QUANTITY,
payload: { id: itemId, quantity }
}),
clearCart: () => ({
type: CART_ACTIONS.CLEAR_CART
}),
applyDiscount: (discountPercent) => ({
type: CART_ACTIONS.APPLY_DISCOUNT,
payload: discountPercent
})
};
// Usage in component
function ProductCard({ product }) {
const { dispatch } = useCart();
const handleAddToCart = () => {
dispatch(cartActions.addItem(product));
};
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={handleAddToCart}>
Add to Cart
</button>
</div>
);
}
Best Practice: Use action creators instead of manually creating action objects. They provide consistency, reduce typos, and make refactoring easier. Consider exporting them from your context file.
Using the Cart Context
Here's how to consume the cart context in components:
// components/Cart.jsx
import { useCart, cartActions } from '../contexts/CartContext';
function Cart() {
const { state, dispatch } = useCart();
const handleUpdateQuantity = (itemId, newQuantity) => {
dispatch(cartActions.updateQuantity(itemId, newQuantity));
};
const handleRemoveItem = (itemId) => {
dispatch(cartActions.removeItem(itemId));
};
const handleClearCart = () => {
if (window.confirm('Clear entire cart?')) {
dispatch(cartActions.clearCart());
}
};
const handleApplyDiscount = () => {
dispatch(cartActions.applyDiscount(10)); // 10% discount
};
if (state.items.length === 0) {
return <div className="cart-empty">Your cart is empty</div>;
}
return (
<div className="cart">
<h2>Shopping Cart ({state.itemCount} items)</h2>
<ul className="cart-items">
{state.items.map(item => (
<li key={item.id} className="cart-item">
<div className="item-info">
<h3>{item.name}</h3>
<p>${item.price} each</p>
</div>
<div className="item-quantity">
<button
onClick={() => handleUpdateQuantity(
item.id,
item.quantity - 1
)}
>
-
</button>
<span>{item.quantity}</span>
<button
onClick={() => handleUpdateQuantity(
item.id,
item.quantity + 1
)}
>
+
</button>
</div>
<div className="item-total">
${(item.price * item.quantity).toFixed(2)}
</div>
<button
className="remove-btn"
onClick={() => handleRemoveItem(item.id)}
>
Remove
</button>
</li>
))}
</ul>
<div className="cart-summary">
{state.discount > 0 && (
<p className="discount">
Discount: {state.discount}% off
</p>
)}
<p className="total">
Total: ${state.total.toFixed(2)}
</p>
<div className="cart-actions">
<button onClick={handleApplyDiscount}>
Apply 10% Discount
</button>
<button onClick={handleClearCart}>
Clear Cart
</button>
<button className="checkout-btn">
Checkout
</button>
</div>
</div>
</div>
);
}
Combining Multiple Contexts
For larger applications, you can combine multiple contexts:
// contexts/AppProviders.jsx
import { CartProvider } from './CartContext';
import { AuthProvider } from './AuthContext';
import { ThemeProvider } from './ThemeContext';
export function AppProviders({ children }) {
return (
<ThemeProvider>
<AuthProvider>
<CartProvider>
{children}
</CartProvider>
</AuthProvider>
</ThemeProvider>
);
}
// App.jsx
import { AppProviders } from './contexts/AppProviders';
function App() {
return (
<AppProviders>
<Router>
{/* Your app routes */}
</Router>
</AppProviders>
);
}
Performance Consideration: Context re-renders all consuming components when state changes. For frequently updating state, consider splitting into multiple contexts or using state management libraries like Redux. Use React.memo() and useMemo() to optimize renders.
Advanced Pattern: Context with Selectors
Add selector functions to optimize component renders:
// contexts/CartContext.jsx (advanced)
// Selectors
export const cartSelectors = {
selectItems: (state) => state.items,
selectTotal: (state) => state.total,
selectItemCount: (state) => state.itemCount,
selectItemById: (state, itemId) =>
state.items.find(item => item.id === itemId),
selectIsInCart: (state, itemId) =>
state.items.some(item => item.id === itemId)
};
// Usage
function CartBadge() {
const { state } = useCart();
const itemCount = cartSelectors.selectItemCount(state);
return (
<div className="cart-badge">
<ShoppingCartIcon />
{itemCount > 0 && (
<span className="badge">{itemCount}</span>
)}
</div>
);
}
Practice Exercise 1: Todo App with Context + useReducer
Create a todo management system:
- Create TodoContext with reducer for ADD_TODO, TOGGLE_TODO, DELETE_TODO, FILTER_TODOS
- Build TodoList component that displays filtered todos
- Add TodoForm for adding new todos
- Implement filter buttons (All, Active, Completed)
- Add "Clear Completed" functionality
Practice Exercise 2: Theme System
Build a comprehensive theme management system:
- Create ThemeContext with light/dark/custom themes
- Implement SWITCH_THEME, SET_CUSTOM_COLORS actions
- Add ThemeToggle component with dropdown
- Store theme preference in localStorage
- Apply CSS variables based on current theme
Practice Exercise 3: Multi-Step Form State
Create a wizard form with context:
- Build FormContext with steps: Personal Info, Address, Preferences, Review
- Implement NEXT_STEP, PREV_STEP, UPDATE_FIELD, RESET_FORM actions
- Create navigation with progress indicator
- Validate each step before allowing progression
- Display summary on review step
Summary
Context + useReducer provides a powerful pattern for global state management. Use action types for consistency, create action creators for cleaner code, and consider selectors for performance optimization. This pattern works great for medium-sized applications and provides Redux-like benefits without the extra complexity.