Topic 10 / 14

useReducer & State Architecture

~10 min read  //  React Series  //  Coding India

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 & testableexpect(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.