State Logic Lives in a Function, Not in Handlers
useReducer
`useReducer` is `useState`'s big sibling — for state where the next value depends on what kind of update is happening.
What you'll learn
- Move complex state logic into a reducer
- Dispatch actions instead of calling setters
- Recognize when to upgrade from `useState`
useState is great when state is simple. When state has many
related fields, or transitions are complex, useReducer keeps
everything in one place.
The Shape
function reducer(state, action) {
switch (action.type) {
case "add": return [...state, action.item];
case "remove": return state.filter(x => x.id !== action.id);
case "clear": return [];
default: return state;
}
}
function TodoList() {
const [items, dispatch] = useReducer(reducer, []);
return (
<>
<button onClick={() => dispatch({ type: "add", item: { id: 1, text: "Hi" } })}>
Add
</button>
<button onClick={() => dispatch({ type: "clear" })}>Clear</button>
<ul>
{items.map(t => (
<li key={t.id}>
{t.text}
<button onClick={() => dispatch({ type: "remove", id: t.id })}>x</button>
</li>
))}
</ul>
</>
);
} The reducer is a pure function: (state, action) => newState. The
component dispatches actions; the reducer decides how state
changes.
Why It’s Sometimes Better
- All state transitions are in one place (the reducer) instead of scattered across many handlers
- Easier to test — the reducer is just a function
- Easier to read the state machine — flip through cases
dispatchis stable, so you can pass it to children or down through context withoutuseCallback
When To Use
| Situation | Reach for |
|---|---|
| One value (a string, a boolean, a number) | useState |
| Two or three related values, simple updates | useState |
| Many fields that change together | useReducer |
State machine (loading/error/data transitions) | useReducer |
| Updates that involve multiple fields at once | useReducer |
Action Conventions
Most code uses an object with a type field. Extra payload goes on
the object:
dispatch({ type: "set-name", name: "Ada" });
dispatch({ type: "add", item });
dispatch({ type: "clear" }); Some codebases prefer a payload field by convention; some prefer
flat fields. Either is fine — be consistent.
Lazy Initialization
If computing the initial state is expensive, pass a third argument:
function init(initialUser) {
return { user: initialUser, log: [] };
}
const [state, dispatch] = useReducer(reducer, savedUser, init); init(savedUser) runs once on first render.
Pairing With Context
useReducer shines when paired with context — one provider exposes
both state and dispatch to a whole subtree.
function CartProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initial);
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
} Any descendant can call dispatch({ type: "add", item }) — no prop
drilling. (Plus, dispatch is stable, so no extra renders.)
Up Next
Both useState and useReducer are building blocks. The real
power-up is wrapping them in your own custom hooks.