Topic 10 / 14
useReducer & State Architecture
When useState Stops Scaling
A cart has add, remove, change-quantity, apply-coupon, clear. With useState, that logic smears across event handlers. useReducer centralises it: all transitions live in one pure function, and components just dispatch actions:
import { useReducer } from "react";
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 "set-qty":
return {
...state,
items: state.items.map(i =>
i.id === action.id ? { ...i, qty: action.qty } : i
),
};
case "clear":
return { items: [], coupon: null };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, { items: [], coupon: null });
return (
<>
{state.items.map(item => (
<CartRow
key={item.id}
item={item}
onRemove={() => dispatch({ type: "remove", id: item.id })}
onQty={(qty) => dispatch({ type: "set-qty", id: item.id, qty })}
/>
))}
<button onClick={() => dispatch({ type: "clear" })}>Empty cart</button>
</>
);
}Why This Wins for Complex State
- One place to read — every possible state change is in the reducer.
- Pure & testable —
expect(cartReducer(state, action)).toEqual(...), no rendering needed. - Self-documenting — action types are a vocabulary of what can happen.
- Predictable — components describe what happened, the reducer decides what changes.
The reducer must stay pure: no fetches, no mutation — return new objects (same immutability rules as useState).
Reducer + Context = App-Level State
The classic pairing — global state with a clean dispatch API:
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialCart);
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
export const useCart = () => useContext(CartContext);
// any component:
const { state, dispatch } = useCart();
dispatch({ type: "add", item: course });This is essentially Redux’s architecture, hand-rolled — which is why learning useReducer makes Redux Toolkit trivial later.
Choosing Your State Tool
- useState — independent values, simple updates. Your default.
- useReducer — many related transitions, next state depends on previous, logic worth testing alone.
- Context — distribution (who can see it), not logic. Pairs with either.
- Server state (fetched data) — belongs in TanStack Query / framework loaders, not reducers.
And always: derive what you can (const total = items.reduce(...) in render) — the less state you store, the less can go stale.