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.