What you'll learn
- Write and test a reducer
- Follow the reducer rules: pure, never mutating, unknown actions return the same state
- Connect a reducer to a component with useReducer and dispatch
- Choose between useState and useReducer for a piece of state
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 reducerA pure function that takes the current state and an action and returns the next state without changing the old one. 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 actionA plain object describing what happened, usually with a type field, such as { type: "add", item }. (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); // unchangedObject.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 dispatchThe function returned by useReducer that sends an action to the reducer to compute the next state. 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.
Key terms (5)
- reducer
- A pure function that takes the current state and an action and returns the next state without changing the old one.
- action
- A plain object describing what happened, usually with a type field, such as { type: "add", item }.
- dispatch
- The function returned by useReducer that sends an action to the reducer to compute the next state.
- useReducer
- A hook that takes a reducer and an initial state and returns the current state and a dispatch function.
- Object.freeze
- Makes an object's own properties read-only, so in strict mode any attempt to mutate it throws; it freezes one level only.