Collect state changes into a pure reducer with useReducer, and test it as a plain function.
Why it matters
As a component grows, its event handlers multiply: add an item, remove it, change a quantity, clear the cart, apply a coupon. When each handler pokes at several useState values, the logic gets scattered and hard to test. A reducer gathers every state change into one pure function you can read top to bottom and test without rendering anything.
The idea
A reducer takes the current state and an action (a plain object describing what happened) and returns the next state:
function counterReducer(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:
return state;
}
}
counterReducer({ count: 2 }, { type: "increment" }); // { count: 3 }
The name comes from Array.prototype.reduce: replaying a list of actions through the reducer gives you the final state.
const actions = [{ type: "increment" }, { type: "increment" }, { type: "decrement" }];
actions.reduce(counterReducer, { count: 0 }); // { count: 1 }
Rules for reducers
- Pure. Same state and action, same result. No fetching, no random numbers, no timers, no
console.log-driven logic. - Never mutate. Return new objects and arrays. React compares references to decide whether something changed.
- Unknown actions return the same state object, so nothing re-renders. (Some teams throw an error instead, to catch typos.)
- Actions describe what happened, not how to change state:
{ type: "added", item }reads better than{ type: "setItems", items }.
useReducer in a component
function Counter() {
const [state, dispatch] = React.useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}
useReducer(reducer, initialState) returns the current state and a dispatch function. Event handlers become tiny: they just describe what happened.
A realistic example: a shopping cart
function cartReducer(state, action) {
switch (action.type) {
case "add": {
const exists = state.items.some((i) => i.id === action.item.id);
if (exists) {
return {
...state,
items: state.items.map((i) =>
i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
),
};
}
return { ...state, items: [...state.items, { ...action.item, qty: 1 }] };
}
case "remove":
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case "clear":
return { ...state, items: [] };
default:
return state;
}
}
The patterns to remember:
- Add to an array:
[...items, newItem]. - Remove:
items.filter(...). - Update one item:
items.map((i) => (i.id === id ? { ...i, changed } : i)). - Update a field of the state object:
{ ...state, field: value }.
Note the braces around case "add": { ... }. They give the const its own block, so two cases can each declare a variable with the same name.
Testing a reducer
Because a reducer is a plain function, testing it needs no browser and no React:
const start = Object.freeze({ items: [] });
const once = cartReducer(start, { type: "add", item: { id: "pen", price: 2 } });
const twice = cartReducer(once, { type: "add", item: { id: "pen", price: 2 } });
console.assert(once.items[0].qty === 1);
console.assert(twice.items.length === 1 && twice.items[0].qty === 2);
console.assert(start.items.length === 0); // unchanged
Object.freeze is a handy trick: in strict mode, any attempt to mutate the frozen state throws, so a hidden mutation bug shows up immediately. (It freezes only one level; freeze nested objects too if you want full protection.) In a real project you would write these as Vitest or Jest test cases.
Derived values stay outside
Do not store a cart total in the reducer state; compute it:
function cartTotal(state) {
return state.items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
useState or useReducer?
Use useState when | Use useReducer when |
|---|---|
| One or two independent values | Several values change together |
| Updates are simple (set, toggle) | Many kinds of updates |
| Logic is short | You want to unit-test transitions |
Both are fine; you can start with useState and switch later. Libraries such as Redux use the same reducer idea for app-wide state.
Common mistakes
- Mutating:
state.items.push(item); return state;returns the same reference, so React may not re-render. - Forgetting
default: return state, so unknown actions returnundefinedand wipe the state. - Doing side effects (API calls) inside the reducer. Do them in event handlers or effects, then dispatch the result.
Recap
- A reducer is
(state, action) => newState: pure and never mutating. useReducergives you[state, dispatch]; handlers dispatch action objects.- Use spread,
mapandfilterto build new state. - Reducers are easy to test as plain functions; keep derived values outside them.
Try it
Open the Do tab to write and test a cart reducer with add, remove, set-quantity and clear actions, plus a cartTotal helper.