useReducer: complex state
When useState isn't enough
While state is one or two values, useState is perfect. But in real applications logic grows quickly: a task list
where you can add, check, delete, filter, clear. The code that changes the state spreads over a dozen handlers in
different places of the component, and it's already hard to understand how the state can change at all.
useReducer gathers all the change logic into one function — the reducer. Event handlers no longer say "how to
change", they only report what happened.
Actions and the reducer
function tasksReducer(tasks, action) {
switch (action.type) {
case "added":
return [...tasks, { id: action.id, text: action.text, done: false }];
case "toggled":
return tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case "deleted":
return tasks.filter((t) => t.id !== action.id);
default:
throw new Error("Unknown action: " + action.type);
}
}
- An action is an ordinary object that describes an event. By convention it has a
typefield, and the rest is data:{ type: "deleted", id: 3 }. - A reducer is a function
(currentState, action) → newState. The name comes from the array methodreduce: it also "folds" a sequence into one value.
A reducer can be checked without React, like an ordinary function:
let state = [];
state = tasksReducer(state, { type: "added", id: 1, text: "Buy bread" });
state = tasksReducer(state, { type: "added", id: 2, text: "Call mom" });
state = tasksReducer(state, { type: "toggled", id: 1 });
console.log(state);
useReducer in a component
const { useReducer } = React;
function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, []);
return (
<div>
<button onClick={() => dispatch({ type: "added", id: Date.now(), text: "New" })}>
Add
</button>
<ul>
{tasks.map((t) => (
<li key={t.id}>
<span onClick={() => dispatch({ type: "toggled", id: t.id })}>{t.text}</span>
<button onClick={() => dispatch({ type: "deleted", id: t.id })}>×</button>
</li>
))}
</ul>
</div>
);
}
useReducer(reducer, initialState)returns a pair: the current state and thedispatchfunction.dispatch(action)— "send an action". React calls the reducer with the current state and this action, saves the result and re-renders the component.
Compare with useState: there the handler decides itself what the new state will be. Here the handler only reports
the event, and the reducer decides.
Rules for a reducer
A reducer must be a pure function:
- Don't change the incoming state — return a new array or object (as with
useState, otherwise React won't see the changes). - No side effects: no requests, timers,
Math.random()orDate.now()inside. A random id is better generated in the handler and passed in the action. - For an unknown action — throw an error. A typo in
typebecomes noticeable right away.
Purity gives a big advantage: the result depends only on the input, so a reducer is easy to test, and the behaviour of the application can be reproduced by repeating the same actions.
If you need to declare a variable in a case, wrap it in curly braces — then the variables of different branches won't
conflict:
case "added": {
const id = Math.max(0, ...tasks.map((t) => t.id)) + 1;
return [...tasks, { id, text: action.text, done: false }];
}
useState or useReducer?
useState |
useReducer |
|
|---|---|---|
| How much code | less | more (reducer + actions) |
| Simple values (a flag, a field's text) | ✓ | overkill |
| Many ways to change one state | handlers spread out | all the logic in one place |
| Testing the logic | together with the component | the reducer separately, as a function |
| Debugging | — | you can log every action to the console |
A good rule: start with useState. If there are many handlers changing the same state, switch to useReducer. It's
easy to do: the actions describe what already happens in the handlers.
Reducer + context
useReducer and useContext together are the standard pattern for application-wide state without external libraries:
const TasksContext = createContext(null);
const DispatchContext = createContext(null);
function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, []);
return (
<TasksContext.Provider value={tasks}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</TasksContext.Provider>
);
}
function DeleteButton({ id }) {
const dispatch = useContext(DispatchContext);
return <button onClick={() => dispatch({ type: "deleted", id })}>×</button>;
}
- State and logic are in one place (
TasksProvider+ the reducer). - Any component at any depth reads the tasks (
TasksContext) or sends actions (DispatchContext), without props. - Why two contexts? The
dispatchfunction never changes, so components that need onlydispatch(buttons) won't re-render on every change of the list.
Popular state libraries — Redux and Zustand — are built in a similar way: a store, actions and pure update functions.
Once you understand useReducer, you'll understand them too.
Summary
useReducer(reducer, initial)→[state, dispatch].- An action is an object
{ type, ...data }, it describes what happened. - A reducer is a pure function
(state, action) => newState: no mutations or effects, an unknown action is an error. useState— for simple things,useReducer— when there are many ways to change the state.- A reducer + two contexts (the state and
dispatch) — the state of the whole application.