useReducer
When “a couple of fields” has turned into a small machine
Instead of calling setState in ten places, you write down in one place how the state changes for each event. The component only reports what happened.
useReducer is the big brother of useState. Instead of “set this value to that”, you say “the following happened” – and a single function decides what comes of it.
useState is a switch you flip yourself. useReducer is an order: you call out “one pizza” – the recipe decides what happens in the kitchen. And all the recipes live in one place.
const [state, dispatch] = useReducer(reducer, initial);
// ▲ ▲ ▲
// | | your function: (old, event) => new
// | report "this happened"
// the current state
dispatch({ type: "added", text: "Milk" }); A complete example
type Todo = { id: number; text: string; done: boolean };
// Everything that can happen
type Action =
| { type: "added"; text: string }
| { type: "toggled"; id: number }
| { type: "cleared" };
// What comes of it – an ordinary function
function reducer(todos: Todo[], action: Action): Todo[] {
switch (action.type) {
case "added":
return [...todos, { id: Date.now(), text: action.text, done: false }];
case "toggled":
return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case "cleared":
return [];
default:
return todos;
}
}
function App() {
const [todos, dispatch] = useReducer(reducer, []);
return <button onClick={() => dispatch({ type: "cleared" })}>Delete all</button>;
} Use { type: "form_submitted" } rather than { type: "setLoading" }. One event can set several fields correctly at once (loading: true, error: null) – a setter only knows one and leaves gaps.
The reducer has to behave
function reducer(state, action) {
state.count += 1; // changes the old thing!
localStorage.setItem("c", "1"); // reaches outside!
return state; // same object -> no redraw
} function reducer(state, action) {
return { ...state, count: state.count + 1 };
}
// Reaching outside is the click handler's job:
function click() {
dispatch({ type: "incremented" });
localStorage.setItem("c", String(state.count + 1));
} The reducer must be pureA function is “pure” if it always returns the same thing for the same input and touches nothing else.Your components must be pure while rendering – then React can safely call them more than once.→: compute from the inputs, return a new result, touch nothing else. React deliberately calls it twice in development – with a well-behaved function nobody notices.
useState or useReducer?
- +
Several fields change together (
loading,data,error) - +
The next change depends on the current state in a complicated way
- +
The same logic is triggered from many places (a wizard, a game, undo)
- +
You want to test the logic without drawing anything
- −
A single switch or counter –
useStateis shorter and clearer - −
When the reducer only forwards
state.field = action.value: that isuseStatewith extra steps
Unlike functions you write yourself, dispatch stays the same forever. So you can hand it deep down or put it in effect lists without a second thought.
▸ In more depth: computing the initial value and the never trick optional
useReducer takes a third parameter: a function that computes the starting value on the first pass only. Handy when building it is expensive.
function init(count: number) {
return { fields: Array(count).fill(""), error: null };
}
const [state, dispatch] = useReducer(reducer, 5, init);
// ▲ ▲
// | called once with 5
// argument for init And a useful TypeScript trick: declare a variable of type never in the default branch and the compiler complains as soon as you forget an action.
default: {
const _check: never = action; // error if a case is missing
return state;
} Does it stick?
4 questions on this lesson. Wrong answers show up in your stats.
Now write it yourself
Finish the reducer. The cart should add items (an item already there just goes up in quantity), decrease quantities and be emptied completely.
- □
addcreates a new item with quantity 1 - □The same item again raises the quantity to 2
- □
decreaselowers the quantity; at 0 the item drops out - □
clearremoves everything
import { useReducer } from "react"; type Line = { name: string; qty: number }; type Action = | { type: "add"; name: string } | { type: "decrease"; name: string } | { type: "clear" }; function reducer(state: Line[], action: Action): Line[] { switch (action.type) { case "add": // TODO return state; case "decrease": // TODO return state; case "clear": // TODO return state; default: return state; } } export default function App() { const [cart, dispatch] = useReducer(reducer, []); return ( <div style={{ fontFamily: "system-ui", padding: 16 }}> <button onClick={() => dispatch({ type: "add", name: "Apple" })}> Apple + </button>{" "} <button onClick={() => dispatch({ type: "decrease", name: "Apple" })}> Apple − </button>{" "} <button onClick={() => dispatch({ type: "clear" })}>Clear</button> <ul> {cart.map((l) => ( <li key={l.name} data-testid={"line-" + l.name}> {l.name}: {l.qty} </li> ))} </ul> </div> ); }