When useState Starts to Strain
A component with several related pieces of state, updated by many different event handlers, can become hard to follow — related updates get scattered across the component, and keeping them in sync involves multiple setState calls.
The Reducer Pattern
useReducer centralizes state updates into a single function called a reducer, which takes the current state and an "action" describing what happened, and returns the new state.
A Basic Counter with useReducer
import { useReducer } from "react";
function reducer(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:
throw new Error("Unknown action: " + action.type);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</>
);
}useReducer Return Value
| Item | Purpose |
|---|---|
| state | The current state value |
| dispatch | A function to send an action describing what happened |
Actions with a Payload
An action can carry extra data beyond just its type, letting the reducer make decisions based on the specifics of what happened.
An Action with a Payload
function reducer(state, action) {
switch (action.type) {
case "add_todo":
return [...state, { id: Date.now(), text: action.payload, done: false }];
case "toggle_todo":
return state.map((todo) =>
todo.id === action.payload ? { ...todo, done: !todo.done } : todo
);
default:
return state;
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(reducer, []);
function addTodo(text) {
dispatch({ type: "add_todo", payload: text });
}
// ...
}useState vs useReducer
| useState | useReducer | |
|---|---|---|
| Best for | Simple, independent values | Complex state with several related sub-values or transitions |
| Update logic location | Scattered across event handlers | Centralized in one reducer function |
| Testability | Harder to isolate | Easy — a reducer is a pure function you can test directly |
Reducers Must Be Pure
A reducer should never mutate the existing state or cause side effects — given the same state and action, it must always return the same new state, computed as a new object/array rather than an in-place mutation.
Best Practice
Reach for useReducer once a component’s state updates start depending on each other, or when the next state depends on several parts of the previous state at once — it keeps that logic testable and in one place instead of scattered across handlers.