Module 4: Hooks and Lifecycles
useReducer: State That Has Rules
How reducers centralize update logic, why they must be pure, and how to pair them with context for app-wide state.
useState is perfect for a toggle or a text field. It starts to hurt when one piece of state can change in many different ways, and each way has to remember to update several fields consistently. You end up with event handlers scattered across components, each with its own idea of how to mutate the same data.
useReducer collects all of those transitions into one function, so there's exactly one place that defines how state can change.
The idea#
A reducer is a pure function: given the current state and an action describing what happened, it returns the next state.
function reducer(state, action) {
// return the next state
}
You don't modify state. You send an action with dispatch, and the reducer decides what the new state is.
import { useReducer } from "react";
function counterReducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "set":
return { count: action.value };
case "reset":
return { count: 0 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}
Components describe what happened (increment), not how the data changes. The how lives in the reducer.
click ──► dispatch({type:"increment"})
│
▼
reducer(currentState, action) ──► newState
│
▼
React re-renders with newState
A realistic example: a shopping cart#
This is where reducers earn their keep, because adding an item, changing quantity and removing all touch the same list and the same total.
const initialCart = { items: [] };
function cartReducer(state, action) {
switch (action.type) {
case "add": {
const existing = state.items.find((i) => i.id === action.item.id);
if (existing) {
return {
...state,
items: state.items.map((i) =>
i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
),
};
}
return { ...state, items: [...state.items, { ...action.item, qty: 1 }] };
}
case "remove":
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case "setQty":
return {
...state,
items: state.items.map((i) =>
i.id === action.id ? { ...i, qty: Math.max(1, action.qty) } : i
),
};
case "clear":
return initialCart;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Cart() {
const [cart, dispatch] = useReducer(cartReducer, initialCart);
const total = cart.items.reduce((sum, i) => sum + i.price * i.qty, 0); // derived, not stored
return (
<div>
<button onClick={() => dispatch({ type: "add", item: { id: 1, name: "Pen", price: 20 } })}>
Add pen
</button>
<ul>
{cart.items.map((i) => (
<li key={i.id}>
{i.name} × {i.qty}
<button onClick={() => dispatch({ type: "remove", id: i.id })}>x</button>
</li>
))}
</ul>
<p>Total: {total}</p>
</div>
);
}
A nice property: all the rules (quantity can't go below 1, adding an existing item bumps its quantity) are in one place and you can unit test the reducer without rendering anything.
expect(cartReducer({ items: [] }, { type: "add", item: { id: 1, price: 5 } }))
.toEqual({ items: [{ id: 1, price: 5, qty: 1 }] });
Reducers must be pure#
Same rule as rendering. A reducer can't mutate state, can't make network requests, can't generate random ids or read the current time. StrictMode runs reducers twice in development to catch it.
// Wrong: mutates state
case "add":
state.items.push(action.item);
return state; // same reference, React sees no change
// Wrong: impure
case "add":
return { ...state, items: [...state.items, { ...action.item, id: crypto.randomUUID() }] };
For the second one, generate the id where you dispatch, and carry it inside the action. Keep reducers boring.
useState or useReducer#
My rule of thumb:
- One value, one or two simple ways to change it →
useState. - Several related values that change together, or many different kinds of updates →
useReducer. - The next state depends on complex rules about the previous one →
useReducer. - You'd like to test update logic in isolation →
useReducer.
Neither is better. They're the same mechanism underneath (useState is implemented as a simple reducer). Use whichever keeps the update logic easier to read.
Lazy initial state#
The third argument is an optional initializer, called once:
const [state, dispatch] = useReducer(reducer, savedData, (data) => ({
items: data ?? [],
loadedAt: Date.now(),
}));
Reducer plus context#
Since dispatch has a stable identity across renders, it works very well as the value of a context. Components that only trigger actions can read the dispatch context and won't re-render when state changes.
const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);
export function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, initialCart);
return (
<CartStateContext value={cart}>
<CartDispatchContext value={dispatch}>{children}</CartDispatchContext>
</CartStateContext>
);
}
export function useCart() {
const cart = useContext(CartStateContext);
if (cart === null) throw new Error("useCart must be used inside CartProvider");
return cart;
}
export function useCartDispatch() {
const dispatch = useContext(CartDispatchContext);
if (dispatch === null) throw new Error("useCartDispatch must be used inside CartProvider");
return dispatch;
}
That's a tiny version of what Redux does. If you find yourself building this for half your app's state, the next module's tools might fit better.
Where people get burned#
- Mutating
stateinside the reducer. - Forgetting the
defaultcase, so typos in action types fail silently. Throwing is better. - Returning
undefinedfrom a case branch (forgotreturn). - Doing async work in a reducer. Do the fetch in a handler, then dispatch the result.
- Putting derived values like
totalinside the state and then keeping them in sync, rather than computing them in render.
Practice#
- Convert a to-do list from
useStateintouseReducerwithadd,toggle,removeandclearactions. - Write unit tests (plain functions, no React) for your reducer.
- Build the cart above and add a
setQtyinput per row. - Wrap the cart in the two-context provider, and use
useCartDispatchin an "Add to cart" button that logs each render. Check that it doesn't re-render when the cart changes.