When many event handlers update related state, a reducer makes the logic testable and easier to follow.
As a component grows, state updates get scattered across many handlers. A reducer gathers them into one pure function:
function reducer(state, action) {
switch (action.type) {
case "added": return [...state, { id: action.id, text: action.text, done: false }];
case "deleted": return state.filter(t => t.id !== action.id);
default: throw new Error("Unknown action: " + action.type);
}
}
const [tasks, dispatch] = useReducer(reducer, initialTasks);
dispatch({ type: "added", id: 3, text: "Write docs" });Event handlers now describe what happened (dispatch({ type: "deleted", id })) and the reducer decides how state changes. The reducer receives the current state and the action and returns the next state.
Rules for reducers:
- They must be pure: no requests, timers or mutations; return new objects instead of modifying
state. (Strict Mode calls them twice to check.) - Each action describes one user interaction, even if it changes several fields.
"reset_form"beats five"set_field"actions. - Name actions in the past tense or as events (
"added","item_selected").
When to choose useReducer over useState: several pieces of state change together, the next state depends on the previous in non-trivial ways, or you want to unit-test the logic in isolation (a reducer is just a function). For a boolean or a single input, useState is simpler.
dispatch has a stable identity, so it's safe to pass deep down or put in context; combining a reducer with context is a lightweight app-state pattern. A third argument, init, computes the initial state lazily: useReducer(reducer, arg, init).
Example
import { useReducer } from "react";
function cartReducer(state, action) {
switch (action.type) {
case "added": {
const existing = state.items.find(i => i.id === action.item.id);
const items = existing
? state.items.map(i => (i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i))
: [...state.items, { ...action.item, qty: 1 }];
return { ...state, items };
}
case "removed":
return { ...state, items: state.items.filter(i => i.id !== action.id) };
case "coupon_applied":
return { ...state, discount: action.percent };
default:
throw new Error(`Unknown action ${action.type}`);
}
}
export default function Cart() {
const [cart, dispatch] = useReducer(cartReducer, { items: [], discount: 0 });
const total = cart.items.reduce((s, i) => s + i.price * i.qty, 0) * (1 - cart.discount / 100);
return (
<>
<button onClick={() => dispatch({ type: "added", item: { id: 1, name: "Pen", price: 2 } })}>Add pen</button>
<button onClick={() => dispatch({ type: "coupon_applied", percent: 10 })}>SAVE10</button>
<p>{cart.items.length} lines, total {total.toFixed(2)}</p>
</>
);
}Remember
const [state, dispatch] = useReducer(reducer, initial); handlers dispatch actions- Reducers are pure:
(state, action) => newState, never mutating - Prefer a reducer when related state changes together or logic needs testing
Watch out for
- Doing side effects (fetching, saving) inside the reducer instead of in the event handler.