ποΈ useReducer for Complex State
When a component grows a tangle of interrelated useState calls, updates become scattered and hard to trust. useReducer pulls all that logic into one pure function, replacing "set this, set that" with clear, named actions β the same idea that powers Redux, built right into React.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Decide when useReducer is a better fit than
useState - Write a pure reducer function and dispatch actions to update state
- Model multi-property state (items, totals, status, errors) with predictable transitions
- Use lazy initialization and action creators to keep code clean
- Combine
useReducerwith Context for lightweight app-wide state, and unit-test reducers in isolation
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build a shopping cart reducer that adds, removes, updates quantities, and recomputes the total.
In This Lesson
Why Complex State Hurts
useState is perfect for a handful of independent values. But as a component accumulates related pieces of state β and updates that touch several of them at once β you start to see problems:
- Update logic is scattered across many event handlers.
- Related fields drift out of sync (you update
itemsbut forgettotal). - It's hard to see, in one place, all the ways state can change.
- Testing means rendering the whole component.
π‘ A useful analogy: WithuseStateyou're flipping individual switches and turning dials by hand. WithuseReduceryou issue commands to a control panel β "CHECKOUT", "ADD_ITEM" β and a single rulebook (the reducer) decides exactly which switches and dials move. The rulebook lives in one place, so behavior is predictable and easy to audit.
Anatomy of useReducer
const [state, dispatch] = useReducer(reducer, initialState, init?);
π The four pieces
state β the current state value React hands you each render.
dispatch β a stable function you call with an action to request a change.
reducer β a pure function (state, action) => newState holding all update logic.
init β optional lazy initializer for expensive starting state.
The reducer must be pure
A reducer takes the current state and an action, and returns the next state. It must:
- Be a pure function β same inputs always give the same output.
- Never mutate the existing state; always return a new object.
- Have no side effects β no fetches, no timers, no logging to a server.
Actions describe what happened
An action is a plain object. By convention it has a type string and any extra data (often a payload):
{ type: 'increment' }
{ type: 'addTodo', payload: { id: 1, text: 'Learn useReducer' } }
Here is the whole loop in miniature β a counter:
import { useReducer } from 'react';
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'decrement' })}>β</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
Throwing on an unknown action (rather than silently returning state) catches typos early during development.
useState vs useReducer
Neither hook is "better" β they suit different shapes of state. This table and decision guide will help you choose.
| Consideration | useState | useReducer |
|---|---|---|
| State shape | Few independent values | Complex / interrelated object |
| Update logic | Inline in handlers | Centralized in the reducer |
| How you change it | Call setters directly | Dispatch named actions |
| Predictability | Ad hoc | Structured & explicit |
| Testing | Via the component | Reducer tested in isolation |
| Learning curve | Lower | Higher |
π‘ Don't over-engineer
For the simple counter above, useState is actually more concise. useReducer pays off when the state object has several fields and there are many distinct, related ways to change it β as in the cart below.
Worked Example: Shopping Cart
A cart is the textbook case: multiple items, a derived total, a checkout status, and an error slot β all changing together through several action types.
import { useReducer } from 'react';
const initialState = { items: [], total: 0, isCheckingOut: false, error: null };
// Recompute the total from the items β a single source of truth
const sumTotal = (items) =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0);
function cartReducer(state, action) {
switch (action.type) {
case 'add': {
const product = action.payload;
const existing = state.items.find((i) => i.id === product.id);
const items = existing
? state.items.map((i) =>
i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
)
: [...state.items, { ...product, quantity: 1 }];
return { ...state, items, total: sumTotal(items), error: null };
}
case 'remove': {
const items = state.items.filter((i) => i.id !== action.payload);
return { ...state, items, total: sumTotal(items) };
}
case 'setQuantity': {
const { id, quantity } = action.payload;
if (quantity < 1) return { ...state, error: 'Quantity must be at least 1' };
const items = state.items.map((i) =>
i.id === id ? { ...i, quantity } : i
);
return { ...state, items, total: sumTotal(items), error: null };
}
case 'checkoutStart': return { ...state, isCheckingOut: true, error: null };
case 'checkoutSuccess': return initialState; // empty cart
case 'checkoutError': return { ...state, isCheckingOut: false, error: action.payload };
case 'clear': return initialState;
default: throw new Error(`Unknown action: ${action.type}`);
}
}
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const { items, total, isCheckingOut, error } = state;
const products = [
{ id: 1, name: 'Notebook', price: 4.5 },
{ id: 2, name: 'Pen set', price: 9.0 },
];
async function checkout() {
dispatch({ type: 'checkoutStart' });
try {
await new Promise((r) => setTimeout(r, 800)); // pretend API call
dispatch({ type: 'checkoutSuccess' });
} catch (err) {
dispatch({ type: 'checkoutError', payload: err.message });
}
}
return (
<div>
{error && <p role="alert">{error}</p>}
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} β ${p.price.toFixed(2)}
<button onClick={() => dispatch({ type: 'add', payload: p })}>Add</button>
</li>
))}
</ul>
<h3>Cart</h3>
{items.length === 0 ? (
<p>Your cart is empty.</p>
) : (
<ul>
{items.map((i) => (
<li key={i.id}>
{i.name} Γ
<input
type="number"
min="1"
value={i.quantity}
onChange={(e) =>
dispatch({
type: 'setQuantity',
payload: { id: i.id, quantity: Number(e.target.value) },
})
}
/>
= ${(i.price * i.quantity).toFixed(2)}
<button onClick={() => dispatch({ type: 'remove', payload: i.id })}>
Remove
</button>
</li>
))}
</ul>
)}
<p><strong>Total: ${total.toFixed(2)}</strong></p>
<button onClick={checkout} disabled={items.length === 0 || isCheckingOut}>
{isCheckingOut ? 'Processingβ¦' : 'Checkout'}
</button>
</div>
);
}
β Notice what the reducer buys us
- total is always correct β it's recomputed from
itemsin one place, never updated by hand. - Every way the cart can change is visible in one switch statement.
- Async work (checkout) stays in the component; the reducer only records the result via actions.
Lazy Init, Actions & Context
Lazy initialization
If computing the initial state is expensive (parsing localStorage, heavy math), pass an init function as the third argument. React calls it only on the first render:
function init(startCount) {
return { count: startCount, history: [] };
}
function Counter({ startCount }) {
// `init` runs once, receiving the second argument
const [state, dispatch] = useReducer(reducer, startCount, init);
// β¦
}
Action creators
Small helper functions that build actions keep components tidy and centralize action shape:
const addTodo = (text) => ({
type: 'addTodo',
payload: { id: crypto.randomUUID(), text, done: false },
});
// In the component:
dispatch(addTodo('Buy milk'));
Immer for deep updates
Deeply nested state makes spread-based updates verbose. Immer lets you write "mutating" code that stays immutable under the hood:
import { produce } from 'immer';
function reducer(state, action) {
return produce(state, (draft) => {
switch (action.type) {
case 'renameUser':
draft.user.profile.name = action.payload; // safe "mutation"
break;
case 'toggleTheme':
draft.prefs.theme = draft.prefs.theme === 'dark' ? 'light' : 'dark';
break;
}
});
}
useReducer + Context = lightweight global state
Combine a reducer with Context to share state across a subtree without prop drilling β a Redux-like store with zero dependencies:
import { createContext, useContext, useReducer } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
// Custom hook so consumers never touch the context directly
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used within a CartProvider');
return ctx;
}
π‘ When to graduate to a library
useReducer + Context is ideal for moderate shared state. For large apps with frequent updates across many components, a dedicated store like Zustand or Redux Toolkit adds performance optimizations and devtools. The reducer concept you just learned transfers directly.
Testing Reducers
Because a reducer is a pure function, testing it needs no React, no DOM, and no mocks β just inputs and expected outputs. This is one of the biggest practical wins of the pattern.
import { describe, test, expect } from 'vitest';
import { cartReducer, initialState } from './cartReducer';
describe('cartReducer', () => {
test('adds a new item with quantity 1', () => {
const next = cartReducer(initialState, {
type: 'add',
payload: { id: 1, name: 'Pen', price: 2 },
});
expect(next.items).toHaveLength(1);
expect(next.total).toBe(2);
});
test('increments quantity for an existing item', () => {
const withOne = cartReducer(initialState, {
type: 'add',
payload: { id: 1, name: 'Pen', price: 2 },
});
const withTwo = cartReducer(withOne, {
type: 'add',
payload: { id: 1, name: 'Pen', price: 2 },
});
expect(withTwo.items[0].quantity).toBe(2);
expect(withTwo.total).toBe(4);
});
test('rejects a quantity below 1', () => {
const withOne = cartReducer(initialState, {
type: 'add',
payload: { id: 1, name: 'Pen', price: 2 },
});
const next = cartReducer(withOne, {
type: 'setQuantity',
payload: { id: 1, quantity: 0 },
});
expect(next.error).toMatch(/at least 1/);
});
});
These tests run in milliseconds and document exactly how each action behaves β the reducer becomes living specification.
Hands-on Exercise
ποΈ Build a to-do reducer
Objective: Manage a to-do list entirely through a reducer.
Requirements:
addβ append a new todo{ id, text, done: false }.toggleβ flip thedoneflag for a given id.deleteβ remove a todo by id.clearCompletedβ drop every completed todo at once.- Write one Vitest/Jest test proving
toggleworks.
π‘ Hint
Use map for toggle (return a new object only for the matching id), filter for delete and clearCompleted. Never mutate β always return fresh arrays and objects.
β Solution
export const initialTodos = { items: [] };
export function todoReducer(state, action) {
switch (action.type) {
case 'add':
return {
items: [...state.items, { id: crypto.randomUUID(), text: action.payload, done: false }],
};
case 'toggle':
return {
items: state.items.map((t) =>
t.id === action.payload ? { ...t, done: !t.done } : t
),
};
case 'delete':
return { items: state.items.filter((t) => t.id !== action.payload) };
case 'clearCompleted':
return { items: state.items.filter((t) => !t.done) };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// --- test ---
import { test, expect } from 'vitest';
test('toggle flips done', () => {
const seeded = todoReducer(initialTodos, { type: 'add', payload: 'Ship it' });
const id = seeded.items[0].id;
const toggled = todoReducer(seeded, { type: 'toggle', payload: id });
expect(toggled.items[0].done).toBe(true);
});
Summary & Quiz
π Key Takeaways
useReducercentralizes complex state logic in one pure reducer function.- You dispatch actions (
{ type, payload }) instead of calling many setters. - Reducers must be pure β no mutation, no side effects; return a new state object.
- Reach for it when state is a complex object with many related update paths; keep
useStatefor simple values. - Pair with Context for shared state, and enjoy trivial unit testing of the reducer.
π― Quick Quiz
Question 1: Which statement about a reducer function is correct?
Question 2: When is useReducer clearly preferable to useState?
Question 3: How should the shopping-cart total be kept correct?
π Further Reading
- React docs β useReducer reference
- React docs β Extracting State Logic into a Reducer
- React docs β Scaling Up with Reducer and Context
- Immer β immutable updates made easy
π What's Next?
You've now used useState, useEffect, and useReducer. The next step is packaging your own reusable logic: custom hooks let you extract these patterns into shareable functions like useFetch and useLocalStorage.
π Complex state, tamed!
One reducer to rule them all. Let's make logic reusable with custom hooks.