typescript 156 lines · 4 tabs

Type-Safe Shopping Cart with useReducer, Context, and a useCart Hook

Shared by codesnips Aug 2026
4 tabs
export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export interface CartState {
  items: CartItem[];
}

export type CartAction =
  | { type: "ADD_ITEM"; item: Omit<CartItem, "quantity">; quantity?: number }
  | { type: "REMOVE_ITEM"; id: string }
  | { type: "SET_QUANTITY"; id: string; quantity: number }
  | { type: "CLEAR" };

export const initialCartState: CartState = { items: [] };

function assertNever(x: never): never {
  throw new Error(`Unhandled cart action: ${JSON.stringify(x)}`);
}

export function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "ADD_ITEM": {
      const qty = action.quantity ?? 1;
      const existing = state.items.find((i) => i.id === action.item.id);
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === action.item.id ? { ...i, quantity: i.quantity + qty } : i
          ),
        };
      }
      return { items: [...state.items, { ...action.item, quantity: qty }] };
    }
    case "REMOVE_ITEM":
      return { items: state.items.filter((i) => i.id !== action.id) };
    case "SET_QUANTITY": {
      if (action.quantity <= 0) {
        return { items: state.items.filter((i) => i.id !== action.id) };
      }
      return {
        items: state.items.map((i) =>
          i.id === action.id ? { ...i, quantity: action.quantity } : i
        ),
      };
    }
    case "CLEAR":
      return initialCartState;
    default:
      return assertNever(action);
  }
}
4 files · typescript Explain with highlit

This snippet shows the canonical way to model non-trivial UI state in React without reaching for an external state library: a discriminated-union reducer, a context provider that owns the reducer, and a thin useCart hook that gives components a safe, ergonomic API.

In cartReducer.ts, the cart is treated as a pure data structure and every mutation is expressed as an action. The CartAction type is a discriminated union keyed on type, which lets TypeScript narrow the payload inside each switch branch — adding an unhandled action becomes a compile error, and the default branch uses an assertNever exhaustiveness check to enforce that. cartReducer never mutates its input: ADD_ITEM maps over the existing lines to bump a quantity or appends a new one, and REMOVE_ITEM filters, so React can rely on referential changes to detect updates. Keeping the reducer free of side effects makes it trivially unit-testable and replayable.

CartContext.tsx wires the reducer into the tree. useReducer holds the state, and rather than exposing raw dispatch, the provider builds a small object of intent-revealing methods (addItem, removeItem, setQuantity, clear) plus derived values like totalQuantity and subtotal. These derived totals are computed with useMemo so they only recalculate when state.items actually changes. The whole value object is memoized too, preventing needless re-renders of consumers when unrelated parent state updates. The default context value is undefined, which is deliberate — it powers the guard in the hook.

In useCart.ts, useContext reads the value and throws a clear error when it is undefined, meaning a component was rendered outside CartProvider. This converts a confusing runtime null-access into an immediate, descriptive failure during development.

The pattern's trade-off is boilerplate: three files for what a single useState could start as. The payoff is a single source of truth, testable transition logic, a typed API surface, and controlled re-renders. It is the approach to reach for once cart logic outgrows a component but does not yet warrant Redux or Zustand.


Related snips

Share this code

Here's the card — post it anywhere.

Type-Safe Shopping Cart with useReducer, Context, and a useCart Hook — share card
Link copied