useReducer Hook
Learn how to manage complex state logic with useReducer, when to use it over useState, and how to build scalable state management patterns.
Learning Objectives
- Understand when useReducer is better than useState
- Learn how reducers work and why they're useful
- Master the useReducer hook syntax and patterns
- Build complex state logic with actions and reducers
- Combine useReducer with useContext for global state
- Handle real-world state management scenarios
useReducer Hook
Imagine you're managing a complex machine with many buttons, switches, and dials. useState is like having separate controls for each part, but sometimes you need a central control panel that coordinates everything based on what action you want to perform.
That's exactly what useReducer does! When your component's state becomes complex with multiple related pieces of data that change together, useReducer provides a more organized and predictable way to manage updates.
The Problem with Complex useState
Let's start by understanding when useState becomes unwieldy. Imagine you're building a shopping cart component:
function ShoppingCart() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [total, setTotal] = useState(0); const [discountCode, setDiscountCode] = useState(''); const [discountAmount, setDiscountAmount] = useState(0); const addItem = (product) => { setLoading(true); setError(null); // Simulate API call setTimeout(() => { setItems(prev => [...prev, product]); setTotal(prev => prev + product.price); setLoading(false); }, 1000); }; const removeItem = (productId) => { setItems(prev => prev.filter(item => item.id !== productId)); const removedItem = items.find(item => item.id === productId); if (removedItem) { setTotal(prev => prev - removedItem.price); } }; const applyDiscount = (code) => { setLoading(true); // Simulate validation setTimeout(() => { if (code === 'SAVE10') { setDiscountCode(code); setDiscountAmount(total * 0.1); } else { setError('Invalid discount code'); } setLoading(false); }, 500); }; // This component is getting complex and error-prone! }
Problems with this approach:
- 🔄 Multiple state updates scattered throughout the component
- 🐛 Easy to forget updating related state (like forgetting to update total)
- 🔀 State can get out of sync (items and total might not match)
- 📝 Hard to test individual state logic
- 🧠 Difficult to reason about all possible state combinations
What is useReducer?
useReducer is React's solution for managing complex state. Instead of multiple useState calls, you have:
- One state object that holds all related data
- A reducer function that describes how state changes
- Actions that describe what happened
- Predictable updates that are easy to test and debug
Think of it like this:
- useState: "Set the temperature to 72 degrees"
- useReducer: "Someone pressed the heating button" → reducer figures out what that means
useReducer Basics
Here's the basic syntax:
import React, { useReducer } from 'react'; // 1. Define your initial state const initialState = { count: 0, step: 1 }; // 2. Define your reducer function function counterReducer(state, action) { switch (action.type) { case 'increment': return { ...state, count: state.count + state.step }; case 'decrement': return { ...state, count: state.count - state.step }; case 'setStep': return { ...state, step: action.payload }; case 'reset': return initialState; default: throw new Error('Unknown action type: ' + action.type); } } // 3. Use it in your component function Counter() { const [state, dispatch] = useReducer(counterReducer, initialState); return ( <div> <h2>Count: {state.count}</h2> <p>Step size: {state.step}</p> <button onClick={() => dispatch({ type: 'increment' })}> +{state.step} </button> <button onClick={() => dispatch({ type: 'decrement' })}> -{state.step} </button> <div> <label> Step size: <input type="number" value={state.step} onChange={(e) => dispatch({ type: 'setStep', payload: Number(e.target.value) })} /> </label> </div> <button onClick={() => dispatch({ type: 'reset' })}> Reset </button> </div> ); }
Key concepts:
- State: One object containing all your component's data
- Action: An object describing what happened (always has a
type
) - Dispatch: A function to send actions to the reducer
- Reducer: A pure function that takes current state + action and returns new state
Building a Real Shopping Cart with useReducer
Let's rebuild our shopping cart using useReducer to see the difference:
// 1. Define initial state const initialCartState = { items: [], total: 0, loading: false, error: null, discountCode: '', discountAmount: 0 }; // 2. Define reducer function function cartReducer(state, action) { switch (action.type) { case 'ADD_ITEM_START': return { ...state, loading: true, error: null }; case 'ADD_ITEM_SUCCESS': const newItem = action.payload; return { ...state, items: [...state.items, newItem], total: state.total + newItem.price, loading: false }; case 'ADD_ITEM_ERROR': return { ...state, loading: false, error: action.payload }; case 'REMOVE_ITEM': const itemToRemove = state.items.find(item => item.id === action.payload); return { ...state, items: state.items.filter(item => item.id !== action.payload), total: state.total - (itemToRemove ? itemToRemove.price : 0) }; case 'APPLY_DISCOUNT_START': return { ...state, loading: true, error: null }; case 'APPLY_DISCOUNT_SUCCESS': return { ...state, discountCode: action.payload.code, discountAmount: action.payload.amount, loading: false }; case 'APPLY_DISCOUNT_ERROR': return { ...state, error: action.payload, loading: false }; case 'CLEAR_CART': return { ...initialCartState }; default: throw new Error('Unknown action type: ' + action.type); } } // 3. Use in component function ShoppingCart() { const [state, dispatch] = useReducer(cartReducer, initialCartState); const addItem = async (product) => { dispatch({ type: 'ADD_ITEM_START' }); try { // Simulate API call await new Promise(resolve => setTimeout(resolve, 1000)); dispatch({ type: 'ADD_ITEM_SUCCESS', payload: { ...product, id: Date.now() } }); } catch (error) { dispatch({ type: 'ADD_ITEM_ERROR', payload: 'Failed to add item to cart' }); } }; const removeItem = (itemId) => { dispatch({ type: 'REMOVE_ITEM', payload: itemId }); }; const applyDiscount = async (code) => { dispatch({ type: 'APPLY_DISCOUNT_START' }); try { // Simulate discount validation await new Promise(resolve => setTimeout(resolve, 500)); if (code === 'SAVE10') { dispatch({ type: 'APPLY_DISCOUNT_SUCCESS', payload: { code: code, amount: state.total * 0.1 } }); } else { throw new Error('Invalid discount code'); } } catch (error) { dispatch({ type: 'APPLY_DISCOUNT_ERROR', payload: error.message }); } }; const finalTotal = state.total - state.discountAmount; return ( <div style={{ padding: '20px', maxWidth: '600px' }}> <h2>🛒 Shopping Cart</h2> {/* Error display */} {state.error && ( <div style={{ color: 'red', backgroundColor: '#ffebee', padding: '10px', borderRadius: '5px', marginBottom: '15px' }}> ❌ {state.error} </div> )} {/* Sample products */} <div style={{ marginBottom: '20px' }}> <h3>Available Products</h3> <button onClick={() => addItem({ name: 'Cool T-Shirt', price: 19.99 })} disabled={state.loading} > Add T-Shirt ($19.99) </button> <button onClick={() => addItem({ name: 'Nice Shoes', price: 89.99 })} disabled={state.loading} > Add Shoes ($89.99) </button> <button onClick={() => addItem({ name: 'Awesome Hat', price: 24.99 })} disabled={state.loading} > Add Hat ($24.99) </button> </div> {/* Cart items */} <div style={{ marginBottom: '20px' }}> <h3>Cart Items ({state.items.length})</h3> {state.items.length === 0 ? ( <p>Your cart is empty</p> ) : ( <ul style={{ listStyle: 'none', padding: 0 }}> {state.items.map(item => ( <li key={item.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', border: '1px solid #ddd', marginBottom: '5px', borderRadius: '5px' }}> <span>{item.name} - $" + item.price + "</span> <button onClick={() => removeItem(item.id)}> Remove </button> </li> ))} </ul> )} </div> {/* Discount section */} {state.items.length > 0 && ( <div style={{ marginBottom: '20px' }}> <h4>Apply Discount</h4> <input type="text" placeholder="Enter discount code (try SAVE10)" value={state.discountCode} onChange={(e) => {/* We could add SET_DISCOUNT_CODE action */}} style={{ marginRight: '10px', padding: '5px' }} /> <button onClick={() => applyDiscount('SAVE10')} disabled={state.loading} > Apply Discount </button> {state.discountAmount > 0 && ( <div style={{ color: 'green', marginTop: '5px' }}> ✅ Discount applied: -$" + state.discountAmount.toFixed(2) + " </div> )} </div> )} {/* Total */} {state.items.length > 0 && ( <div style={{ backgroundColor: '#f8f9fa', padding: '15px', borderRadius: '5px', marginBottom: '15px' }}> <div>Subtotal: $" + state.total.toFixed(2) + "</div> {state.discountAmount > 0 && ( <div>Discount: -$" + state.discountAmount.toFixed(2) + "</div> )} <div style={{ fontSize: '18px', fontWeight: 'bold' }}> Total: $" + finalTotal.toFixed(2) + " </div> </div> )} {/* Actions */} {state.items.length > 0 && ( <div> <button style={{ backgroundColor: '#28a745', color: 'white', padding: '10px 20px', border: 'none', borderRadius: '5px', marginRight: '10px' }} > Checkout </button> <button onClick={() => dispatch({ type: 'CLEAR_CART' })} style={{ backgroundColor: '#dc3545', color: 'white', padding: '10px 20px', border: 'none', borderRadius: '5px' }} > Clear Cart </button> </div> )} {state.loading && ( <div style={{ textAlign: 'center', marginTop: '15px' }}> 🔄 Loading... </div> )} </div> ); }
Benefits of this approach:
- ✅ All state logic is centralized in the reducer
- ✅ State updates are predictable and follow the same pattern
- ✅ Easy to test reducer functions in isolation
- ✅ Impossible to have inconsistent state (total always matches items)
- ✅ Clear action names make it obvious what's happening
When to Use useReducer vs useState
Use useState when:
✅ Simple, independent state (a boolean, a string, a number)
✅ State changes are straightforward (toggle, set value)
✅ No complex relationships between state pieces
✅ Component state is small and easy to manage
// Good for useState const [isOpen, setIsOpen] = useState(false); const [name, setName] = useState(''); const [count, setCount] = useState(0);
Use useReducer when:
✅ Complex state object with multiple related properties
✅ State transitions are complex or follow specific rules
✅ Multiple ways to update the same state
✅ You need predictable state management
✅ Logic should be testable outside the component
// Good for useReducer const [state, dispatch] = useReducer(formReducer, { values: {}, errors: {}, touched: {}, isSubmitting: false });
Real-World Example: Form Management
Let's build a complex form with validation using useReducer:
// Form state and reducer const initialFormState = { values: { name: '', email: '', password: '', confirmPassword: '' }, errors: {}, touched: {}, isSubmitting: false, isValid: false }; function formReducer(state, action) { switch (action.type) { case 'FIELD_CHANGE': const newValues = { ...state.values, [action.field]: action.value }; // Validate field const newErrors = { ...state.errors }; const validation = validateField(action.field, action.value, newValues); if (validation.isValid) { delete newErrors[action.field]; } else { newErrors[action.field] = validation.message; } return { ...state, values: newValues, errors: newErrors, isValid: Object.keys(newErrors).length === 0 }; case 'FIELD_BLUR': return { ...state, touched: { ...state.touched, [action.field]: true } }; case 'SUBMIT_START': return { ...state, isSubmitting: true }; case 'SUBMIT_SUCCESS': return { ...initialFormState }; case 'SUBMIT_ERROR': return { ...state, isSubmitting: false, errors: { ...state.errors, submit: action.payload } }; case 'RESET_FORM': return initialFormState; default: throw new Error('Unknown action type: ' + action.type); } } // Validation helper function validateField(fieldName, value, allValues) { switch (fieldName) { case 'name': if (!value.trim()) { return { isValid: false, message: 'Name is required' }; } if (value.length < 2) { return { isValid: false, message: 'Name must be at least 2 characters' }; } return { isValid: true }; case 'email': const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!value) { return { isValid: false, message: 'Email is required' }; } if (!emailRegex.test(value)) { return { isValid: false, message: 'Please enter a valid email' }; } return { isValid: true }; case 'password': if (!value) { return { isValid: false, message: 'Password is required' }; } if (value.length < 6) { return { isValid: false, message: 'Password must be at least 6 characters' }; } return { isValid: true }; case 'confirmPassword': if (!value) { return { isValid: false, message: 'Please confirm your password' }; } if (value !== allValues.password) { return { isValid: false, message: 'Passwords do not match' }; } return { isValid: true }; default: return { isValid: true }; } } // Form component function RegistrationForm() { const [state, dispatch] = useReducer(formReducer, initialFormState); const handleFieldChange = (field, value) => { dispatch({ type: 'FIELD_CHANGE', field, value }); }; const handleFieldBlur = (field) => { dispatch({ type: 'FIELD_BLUR', field }); }; const handleSubmit = async (e) => { e.preventDefault(); if (!state.isValid) { // Mark all fields as touched to show errors Object.keys(state.values).forEach(field => { dispatch({ type: 'FIELD_BLUR', field }); }); return; } dispatch({ type: 'SUBMIT_START' }); try { // Simulate API call await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate random success/failure if (Math.random() > 0.3) { dispatch({ type: 'SUBMIT_SUCCESS' }); alert('Registration successful!'); } else { throw new Error('Server error occurred'); } } catch (error) { dispatch({ type: 'SUBMIT_ERROR', payload: error.message }); } }; const getFieldError = (fieldName) => { return state.touched[fieldName] && state.errors[fieldName]; }; return ( <div style={{ padding: '20px', maxWidth: '400px' }}> <h2>📝 Registration Form</h2> <form onSubmit={handleSubmit}> {/* Name field */} <div style={{ marginBottom: '15px' }}> <label style={{ display: 'block', marginBottom: '5px' }}> Name * </label> <input type="text" value={state.values.name} onChange={(e) => handleFieldChange('name', e.target.value)} onBlur={() => handleFieldBlur('name')} style={{ width: '100%', padding: '8px', border: '1px solid ' + (getFieldError('name') ? 'red' : '#ddd'), borderRadius: '4px' }} /> {getFieldError('name') && ( <div style={{ color: 'red', fontSize: '14px', marginTop: '5px' }}> {getFieldError('name')} </div> )} </div> {/* Email field */} <div style={{ marginBottom: '15px' }}> <label style={{ display: 'block', marginBottom: '5px' }}> Email * </label> <input type="email" value={state.values.email} onChange={(e) => handleFieldChange('email', e.target.value)} onBlur={() => handleFieldBlur('email')} style={{ width: '100%', padding: '8px', border: '1px solid ' + (getFieldError('email') ? 'red' : '#ddd'), borderRadius: '4px' }} /> {getFieldError('email') && ( <div style={{ color: 'red', fontSize: '14px', marginTop: '5px' }}> {getFieldError('email')} </div> )} </div> {/* Password field */} <div style={{ marginBottom: '15px' }}> <label style={{ display: 'block', marginBottom: '5px' }}> Password * </label> <input type="password" value={state.values.password} onChange={(e) => handleFieldChange('password', e.target.value)} onBlur={() => handleFieldBlur('password')} style={{ width: '100%', padding: '8px', border: '1px solid ' + (getFieldError('password') ? 'red' : '#ddd'), borderRadius: '4px' }} /> {getFieldError('password') && ( <div style={{ color: 'red', fontSize: '14px', marginTop: '5px' }}> {getFieldError('password')} </div> )} </div> {/* Confirm Password field */} <div style={{ marginBottom: '20px' }}> <label style={{ display: 'block', marginBottom: '5px' }}> Confirm Password * </label> <input type="password" value={state.values.confirmPassword} onChange={(e) => handleFieldChange('confirmPassword', e.target.value)} onBlur={() => handleFieldBlur('confirmPassword')} style={{ width: '100%', padding: '8px', border: '1px solid ' + (getFieldError('confirmPassword') ? 'red' : '#ddd'), borderRadius: '4px' }} /> {getFieldError('confirmPassword') && ( <div style={{ color: 'red', fontSize: '14px', marginTop: '5px' }}> {getFieldError('confirmPassword')} </div> )} </div> {/* Submit error */} {state.errors.submit && ( <div style={{ color: 'red', backgroundColor: '#ffebee', padding: '10px', borderRadius: '4px', marginBottom: '15px' }}> ❌ {state.errors.submit} </div> )} {/* Submit button */} <button type="submit" disabled={state.isSubmitting || !state.isValid} style={{ width: '100%', padding: '12px', backgroundColor: state.isValid ? '#28a745' : '#6c757d', color: 'white', border: 'none', borderRadius: '4px', fontSize: '16px', cursor: state.isValid ? 'pointer' : 'not-allowed' }} > {state.isSubmitting ? 'Creating Account...' : 'Create Account'} </button> <button type="button" onClick={() => dispatch({ type: 'RESET_FORM' })} style={{ width: '100%', padding: '8px', backgroundColor: 'transparent', color: '#6c757d', border: '1px solid #6c757d', borderRadius: '4px', marginTop: '10px', cursor: 'pointer' }} > Reset Form </button> </form> {/* Debug info */} <details style={{ marginTop: '20px', fontSize: '12px' }}> <summary>Debug Info</summary> <pre>{JSON.stringify(state, null, 2)}</pre> </details> </div> ); }
This form demonstrates:
- Complex state management with validation
- Coordinated updates between values, errors, and touched fields
- Predictable state transitions for all form actions
- Easy testing since all logic is in the reducer
Combining useReducer with useContext
For global state management, you can combine useReducer with useContext:
// Create context for global state const AppStateContext = createContext(); // App state reducer const initialAppState = { user: null, notifications: [], theme: 'light', settings: { emailNotifications: true, pushNotifications: false } }; function appReducer(state, action) { switch (action.type) { case 'LOGIN': return { ...state, user: action.payload }; case 'LOGOUT': return { ...state, user: null, notifications: [] }; case 'ADD_NOTIFICATION': return { ...state, notifications: [...state.notifications, action.payload] }; case 'REMOVE_NOTIFICATION': return { ...state, notifications: state.notifications.filter(n => n.id !== action.payload) }; case 'TOGGLE_THEME': return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' }; case 'UPDATE_SETTINGS': return { ...state, settings: { ...state.settings, ...action.payload } }; default: throw new Error('Unknown action type: ' + action.type); } } // Provider component function AppStateProvider({ children }) { const [state, dispatch] = useReducer(appReducer, initialAppState); return ( <AppStateContext.Provider value={{ state, dispatch }}> {children} </AppStateContext.Provider> ); } // Custom hook to use app state function useAppState() { const context = useContext(AppStateContext); if (!context) { throw new Error('useAppState must be used within AppStateProvider'); } return context; } // Component using global state function UserDashboard() { const { state, dispatch } = useAppState(); const handleLogin = () => { dispatch({ type: 'LOGIN', payload: { id: 1, name: 'John Doe', email: 'john@example.com' } }); dispatch({ type: 'ADD_NOTIFICATION', payload: { id: Date.now(), message: 'Welcome back!', type: 'success' } }); }; const handleLogout = () => { dispatch({ type: 'LOGOUT' }); }; return ( <div style={{ padding: '20px', backgroundColor: state.theme === 'dark' ? '#333' : '#fff', color: state.theme === 'dark' ? '#fff' : '#333', minHeight: '100vh' }}> <h1>Dashboard</h1> {state.user ? ( <div> <p>Welcome, {state.user.name}!</p> <button onClick={handleLogout}>Logout</button> </div> ) : ( <button onClick={handleLogin}>Login</button> )} <button onClick={() => dispatch({ type: 'TOGGLE_THEME' })}> Switch to {state.theme === 'light' ? 'Dark' : 'Light'} Mode </button> {/* Notifications */} {state.notifications.map(notification => ( <div key={notification.id} style={{ backgroundColor: '#e3f2fd', padding: '10px', margin: '10px 0', borderRadius: '5px' }}> {notification.message} <button onClick={() => dispatch({ type: 'REMOVE_NOTIFICATION', payload: notification.id })}> × </button> </div> ))} </div> ); } // App component function App() { return ( <AppStateProvider> <UserDashboard /> </AppStateProvider> ); }
Practice Exercise: Build a Todo App with Categories
Try building a todo app with these features using useReducer:
Requirements:
- Add/edit/delete todos
- Mark todos as complete
- Organize todos by categories
- Filter todos (all, active, completed)
- Bulk actions (mark all complete, delete completed)
Starter structure:
const initialTodoState = { todos: [], categories: ['Work', 'Personal', 'Shopping'], filter: 'all', // 'all', 'active', 'completed' selectedCategory: 'all' }; function todoReducer(state, action) { switch (action.type) { case 'ADD_TODO': // Your code here break; case 'TOGGLE_TODO': // Your code here break; case 'DELETE_TODO': // Your code here break; case 'SET_FILTER': // Your code here break; // Add more cases... default: throw new Error('Unknown action: ' + action.type); } } function TodoApp() { const [state, dispatch] = useReducer(todoReducer, initialTodoState); // Your component code here }
Common useReducer Patterns
Pattern 1: Action Creators
// Instead of dispatching objects directly dispatch({ type: 'ADD_TODO', payload: { text: 'Learn React', category: 'Work' } }); // Create action creator functions const addTodo = (text, category) => ({ type: 'ADD_TODO', payload: { text, category } }); const toggleTodo = (id) => ({ type: 'TOGGLE_TODO', payload: id }); // Usage dispatch(addTodo('Learn React', 'Work')); dispatch(toggleTodo(123));
Pattern 2: Async Actions with useEffect
function DataComponent() { const [state, dispatch] = useReducer(dataReducer, initialState); useEffect(() => { const fetchData = async () => { dispatch({ type: 'FETCH_START' }); try { const data = await api.getData(); dispatch({ type: 'FETCH_SUCCESS', payload: data }); } catch (error) { dispatch({ type: 'FETCH_ERROR', payload: error.message }); } }; fetchData(); }, []); return ( <div> {state.loading && <p>Loading...</p>} {state.error && <p>Error: {state.error}</p>} {state.data && <DataDisplay data={state.data} />} </div> ); }
Pattern 3: Middleware-like Logging
function loggingReducer(reducer) { return (state, action) => { console.log('Action:', action); console.log('Previous State:', state); const newState = reducer(state, action); console.log('New State:', newState); return newState; }; } // Usage const [state, dispatch] = useReducer(loggingReducer(myReducer), initialState);
What We've Learned
Congratulations! You now understand:
✅ When useReducer is better than useState
✅ How to write reducer functions and actions
✅ Complex state management patterns
✅ Combining useReducer with useContext
✅ Real-world applications like forms and shopping carts
✅ Best practices and common patterns
Quick Recap Quiz
Test your useReducer knowledge:
- What are the three things useReducer takes as arguments?
- What must every action object have?
- When should you choose useReducer over useState?
- What makes reducer functions "pure"?
Answers: 1) Reducer function, initial state, and optional init function, 2) A type property, 3) When state is complex or state transitions need to be predictable, 4) They don't mutate state and always return the same output for the same inputs
What's Next?
In our next lesson, we'll learn about Custom Hooks - how to extract and reuse stateful logic across components. You'll discover how to create your own hooks that encapsulate complex functionality and make your components cleaner and more reusable.
useReducer provides the foundation for building powerful custom hooks that manage complex state!