React.js Fundamentals

State Management with Context + useReducer

18 min Lesson 21 of 40

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:

  1. Create TodoContext with reducer for ADD_TODO, TOGGLE_TODO, DELETE_TODO, FILTER_TODOS
  2. Build TodoList component that displays filtered todos
  3. Add TodoForm for adding new todos
  4. Implement filter buttons (All, Active, Completed)
  5. Add "Clear Completed" functionality

Practice Exercise 2: Theme System

Build a comprehensive theme management system:

  1. Create ThemeContext with light/dark/custom themes
  2. Implement SWITCH_THEME, SET_CUSTOM_COLORS actions
  3. Add ThemeToggle component with dropdown
  4. Store theme preference in localStorage
  5. Apply CSS variables based on current theme

Practice Exercise 3: Multi-Step Form State

Create a wizard form with context:

  1. Build FormContext with steps: Personal Info, Address, Preferences, Review
  2. Implement NEXT_STEP, PREV_STEP, UPDATE_FIELD, RESET_FORM actions
  3. Create navigation with progress indicator
  4. Validate each step before allowing progression
  5. 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.