Chapter 4 of 4
Context with a Reducer
The standard pattern for app-wide state without a library.
Context delivers a value; it does not manage one. Pairing it with useReducer gives you a small, predictable store with no dependencies.
const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case "added":
return [...state, action.item];
case "removed":
return state.filter((item) => item.id !== action.id);
default:
throw new Error("Unknown action: " + action.type);
}
}
export function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, []);
return (
<CartStateContext.Provider value={cart}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
export const useCart = () => useContext(CartStateContext);
export const useCartDispatch = () => useContext(CartDispatchContext);Splitting state and dispatch into two contexts is deliberate. dispatch never changes identity, so components that only dispatch actions never re-render when the cart changes.
function AddToCartButton({ item }) {
const dispatch = useCartDispatch(); // does not re-render when the cart changes
return <button onClick={() => dispatch({ type: "added", item })}>Add</button>;
}What you have learned
- Try composition before context; it keeps data flow visible.
- Create, provide, consume - and wrap consumption in a custom hook that validates the provider exists.
- Memoise object values, or every consumer re-renders on every provider render.
- Split state and dispatch, or fast-changing and slow-changing values, into separate contexts.
The next tutorial covers useReducer itself in detail, including how to design actions and when a reducer beats a pile of useState calls.