useReducer
useReducer is the Hook for managing state whose next value is computed from the previous value through well-defined transitions. Instead of scattering setState calls across event handlers, you describe every change as an action and centralize the update logic in a single pure reducer function. This makes complex state—forms with many fields, undo stacks, carts, wizards—easier to reason about, test, and debug. This page covers the reducer signature, dispatch, when it beats useState, common action patterns, lazy initialization, and combining it with context.
The reducer signature
A reducer is a pure function with the shape (state, action) => newState. It receives the current state and an action object, then returns the next state—never mutating the original. The action is just a plain object; by convention it has a type field and any extra data the update needs.
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 "set":
return { count: action.value };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "set", value: 0 })}>Reset</button>
</div>
);
}
useReducer returns a [state, dispatch] pair. Calling dispatch(action) runs the reducer with the current state and the action, then schedules a re-render with the returned value.
The reducer must be pure: no API calls, no mutation, no
Date.now()or random values inside it. Same(state, action)in, samenewStateout. Side effects belong in event handlers oruseEffect.
useReducer vs useState
Both Hooks store state and trigger re-renders. The difference is where the update logic lives. useState is ideal for independent, simple values. useReducer shines when updates are interrelated or follow rules.
| Concern | useState | useReducer |
|---|---|---|
| Best for | Simple, independent values | Complex/related transitions |
| Update logic | Inline in handlers | Centralized in one reducer |
| Testability | Test the component | Test the reducer as a plain function |
| Multiple sub-values | Several setter calls | One dispatch per action |
| Debugging | Trace each setter | Log actions in one place |
A good rule: when several pieces of state always change together, or when “what happened” is clearer than “what the new value is,” reach for useReducer.
A worked example: a shopping cart
Carts are a classic fit—adding, removing, and changing quantities all touch the same list and depend on its current contents.
import { useReducer } from "react";
function cartReducer(items, action) {
switch (action.type) {
case "add": {
const existing = items.find((i) => i.id === action.product.id);
if (existing) {
return items.map((i) =>
i.id === action.product.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...items, { ...action.product, qty: 1 }];
}
case "remove":
return items.filter((i) => i.id !== action.id);
case "setQty":
return items.map((i) =>
i.id === action.id ? { ...i, qty: Math.max(1, action.qty) } : i
);
case "clear":
return [];
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Cart() {
const [items, dispatch] = useReducer(cartReducer, []);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return (
<div>
<button
onClick={() =>
dispatch({ type: "add", product: { id: 1, name: "Mug", price: 12 } })
}
>
Add Mug
</button>
<ul>
{items.map((i) => (
<li key={i.id}>
{i.name} × {i.qty} — ${i.price * i.qty}
<button onClick={() => dispatch({ type: "remove", id: i.id })}>
Remove
</button>
</li>
))}
</ul>
<strong>Total: ${total}</strong>
</div>
);
}
Output:
// After clicking "Add Mug" twice:
Mug × 2 — $24 [Remove]
Total: $24
Every cart mutation lives in cartReducer. Components just describe intent (add, remove), and the total is derived during render instead of being stored.
Lazy initialization
useReducer accepts an optional third argument: an init function. React calls init(initialArg) once to produce the starting state. This is useful for expensive setup or for reusing the same logic to reset state.
function init(initialCount) {
return { count: initialCount, history: [] };
}
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1, history: [...state.history, state.count] };
case "reset":
return init(action.payload); // reuse init to reset cleanly
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter({ start = 0 }) {
const [state, dispatch] = useReducer(reducer, start, init);
return (
<button onClick={() => dispatch({ type: "reset", payload: start })}>
{state.count}
</button>
);
}
Passing start as the second argument and init as the third keeps the initial computation out of the render path and lets the reset action recreate the exact initial shape.
Combining with context
Because dispatch is stable—React guarantees its identity never changes between renders—it is perfect for sharing through context without causing extra re-renders. A common pattern splits state and dispatch into two providers so components that only dispatch don’t re-render when state changes.
import { createContext, useContext, useReducer } from "react";
const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);
export function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
return (
<CartStateContext.Provider value={items}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
export const useCart = () => useContext(CartStateContext);
export const useCartDispatch = () => useContext(CartDispatchContext);
Now any descendant calls useCartDispatch() to send actions, while useCart() reads the current items. This is a lightweight, dependency-free alternative to Redux for app-wide state.
Keep actions descriptive (
"checkout", not"setState"). Action names form a log of what the user did, which is invaluable when debugging with React DevTools.
Best Practices
- Keep the reducer pure—no mutations, no side effects; always return a new state object or array.
- Model actions around user intent (
"itemAdded") rather than raw setters, so the action log reads like a story. - Throw on unknown action types during development to catch typos early instead of silently returning stale state.
- Derive computed values (totals, filtered lists) during render; store only the minimal source-of-truth in the reducer.
- Pass
dispatchthrough context to avoid prop-drilling—it’s stable, so it won’t trigger needless re-renders. - Use the lazy
initargument both for expensive setup and to reuse one function for initialization and reset. - Reach for
useReducerwhen several related values change together; stick withuseStatefor simple, independent ones.