Lesson 15 / 25
useReducer for Complex State
Centralise update logic in a pure function.
Actions in, new state out
When state has several related parts or many ways to change, useReducer(reducer, initial) moves the update logic into a reducer: a pure function (state, action) => newState. Components dispatch actions describing what happened ({type: "add", item}), and the reducer decides how state changes. Because reducers are pure, they are easy to test without rendering anything, and all transitions are visible in one place.
A shopping cart reducer, run
I ran this with React 19.3 and react-dom 19.3 on Node.js 22, compiling JSX with esbuild 0.28. Browser-style examples use jsdom 29 as a fake DOM (imported from a small setup-dom.mjs file) and act() to apply updates. Adding a pen twice and a book gives "pen x2, book x1" with a total of 290; removing the pen leaves the book at 250. The reducer is also called directly, outside any component, to show it is a plain pure function.
import "./setup-dom.mjs"; // test setup: jsdom provides a browser-like DOM in Node
import { useReducer, act } from "react";
import { createRoot } from "react-dom/client";
function cartReducer(state, action) {
switch (action.type) {
case "add": {
const found = state.find(i => i.id === action.item.id);
return found ? state.map(i => i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i)
: [...state, { ...action.item, qty: 1 }];
}
case "remove": return state.filter(i => i.id !== action.id);
default: throw new Error(`unknown action ${action.type}`);
}
}
let api;
function Cart() {
const [items, dispatch] = useReducer(cartReducer, []);
api = dispatch;
const total = items.reduce((s, i) => s + i.qty * i.price, 0);
return <p>{items.map(i => `${i.name} x${i.qty}`).join(", ") || "empty"} | total {total}</p>;
}
const root = createRoot(document.getElementById("root"));
await act(() => root.render(<Cart />));
await act(() => api({ type: "add", item: { id: 1, name: "pen", price: 20 } }));
await act(() => api({ type: "add", item: { id: 1, name: "pen", price: 20 } }));
await act(() => api({ type: "add", item: { id: 2, name: "book", price: 250 } }));
console.log(document.querySelector("p").textContent);
await act(() => api({ type: "remove", id: 1 }));
console.log(document.querySelector("p").textContent);
console.log("reducer is a pure function:", JSON.stringify(cartReducer([], { type: "add", item: { id: 9, name: "x", price: 1 } })));
Output:
pen x2, book x1 | total 290
book x1 | total 250
reducer is a pure function: [{"id":9,"name":"x","price":1,"qty":1}]Throw on unknown actions
A default case that throws catches typos in action types immediately.
Quick check: What is a reducer?
- A pure function that takes state and an action and returns new state
- A function that mutates the DOM
- A component that renders lists
- A network request
Answer
A pure function that takes state and an action and returns new state — Pure and testable.