Runnable lesson · press Try it on the examples to edit and run them here.
When a component has many pieces of state and many ways to change them, the update logic gets scattered across event handlers. A reducer collects all of it in one function, so you can see every possible change at a glance.
The three ideas
- State: the current data.
- Action: a small object describing what happened (
{ type: "added", item }). - Reducer: a pure function
(state, action) => newStatethat decides how state changes for each action.
const [state, dispatch] = useReducer(reducer, initialState);
dispatch(action) sends an action to the reducer. React calls reducer(currentState, action) and re-renders with the result.
A todo list with a reducer
function todosReducer(todos, action) {
switch (action.type) {
case "added":
return [...todos, { id: action.id, text: action.text, done: false }];
case "toggled":
return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case "deleted":
return todos.filter((t) => t.id !== action.id);
default:
throw new Error("Unknown action: " + action.type);
}
}
let nextId = 1;
function App() {
const [todos, dispatch] = React.useReducer(todosReducer, []);
const [text, setText] = React.useState("");
function handleAdd() {
if (!text.trim()) return;
dispatch({ type: "added", id: nextId++, text: text.trim() });
setText("");
}
const doneCount = todos.filter((t) => t.done).length;
return (
<div>
<input aria-label="New todo" value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={handleAdd}>Add</button>
<p>{todos.length} todos, {doneCount} done</p>
<ul>
{todos.map((t) => (
<li key={t.id}>
<span style={{ textDecoration: t.done ? "line-through" : "none" }}>{t.text}</span>{" "}
<button aria-label={"Toggle " + t.text} onClick={() => dispatch({ type: "toggled", id: t.id })}>Toggle {t.text}</button>{" "}
<button aria-label={"Delete " + t.text} onClick={() => dispatch({ type: "deleted", id: t.id })}>Delete {t.text}</button>
</li>
))}
</ul>
</div>
);
}
Compare this with the useState version in Updating Arrays in State. The handlers shrink to “what happened” and the rules (how the array changes) live in the reducer, where they are easy to read and to test.
Reducer rules
- Pure. Same state and action always give the same result. No fetching, no random numbers, no
Date.now()(generate ids in the handler, asnextId++above). - Never mutate the state. Return a new object or array (Avoiding State Mutation).
- One action = one thing that happened, named for the event (
"added","toggled"), not for the setter ("set_todos"). - Throw on unknown actions, so typos fail loudly.
Reducer outside the component
Because a reducer is a plain function, you can test it without React:
const result = todosReducer([], { type: "added", id: 1, text: "Learn" });
// [{ id: 1, text: "Learn", done: false }]
useState or useReducer?
useState | useReducer | |
|---|---|---|
| Code size | Less for simple state | More setup |
| Many related fields | Several setters | One place for all rules |
| Update depends on other fields | Scattered | Natural |
| Many event handlers change the same state | Logic repeated | Logic shared |
| Testing the logic | Test the component | Test the reducer |
| Debugging | console.log handlers | Log every action in one place |
Start with useState. Switch to useReducer when you notice handlers doing complicated work on the same piece of state, or when the update logic needs tests.
Reducer plus Context
Put state and dispatch in context (Context API), and any component can dispatch actions. This gives you a small store with no extra library:
const CounterContext = React.createContext(null);
function counterReducer(state, action) {
switch (action.type) {
case "incremented": return { total: state.total + 1 };
case "reset": return { total: 0 };
default: throw new Error("Unknown action: " + action.type);
}
}
function CounterProvider({ children }) {
const [state, dispatch] = React.useReducer(counterReducer, { total: 0 });
const value = React.useMemo(() => ({ state, dispatch }), [state]);
return <CounterContext value={value}>{children}</CounterContext>;
}
function Controls() {
const { dispatch } = React.useContext(CounterContext);
return (
<div>
<button onClick={() => dispatch({ type: "incremented" })}>Add</button>{" "}
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}
function Display() {
const { state } = React.useContext(CounterContext);
return <p>Total: {state.total}</p>;
}
function App() {
return (
<CounterProvider>
<Controls />
<Display />
</CounterProvider>
);
}
Lazy initial state
useReducer(reducer, arg, init) takes an optional third argument: a function that computes the initial state from arg, the same idea as a lazy useState initializer.
Common mistakes
- Mutating state inside the reducer (
state.items.push(x)). - Side effects in the reducer (network calls, logging to a server).
- A giant reducer that handles unrelated things. Split it, or use several reducers.
- Dispatching with no
type, or with a type that the reducer does not handle.
Key points
- A reducer is a pure
(state, action) => newStatefunction;dispatchsends it actions. - Use it when many handlers change related state; start with
useStateotherwise. - Combine with Context to share state and actions across the app.