Back to Tutorial
Advanced Hooks
Hands-on Practice
40 min

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:

  1. One state object that holds all related data
  2. A reducer function that describes how state changes
  3. Actions that describe what happened
  4. 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:

  1. Add/edit/delete todos
  2. Mark todos as complete
  3. Organize todos by categories
  4. Filter todos (all, active, completed)
  5. 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:

  1. What are the three things useReducer takes as arguments?
  2. What must every action object have?
  3. When should you choose useReducer over useState?
  4. 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!