Skip to main content
React · lesson 9 of 22
React intermediate Lesson 9 of 22

useReducer and useContext

Test a cart reducer as a plain function with no rendering, then share it through context and prove the provider guard throws without one.

By SkillByExample · Updated

react hooks useReducer useContext

A reducer is a plain function, (state, action) => newState — it can be tested with no rendering at all. useContext shares that reducer’s state down the tree without threading props through every level by hand.

Testing the Reducer With No Rendering

cartReducer never touches React — call it directly and check what comes back.

// src/reducer-context/cartReducer.ts
type CartItem = { id: number; qty: number };
type CartState = { items: CartItem[] };
type CartAction = { type: "add"; id: number } | { type: "remove"; id: number } | { type: "clear" };

export function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "add": {
      const existing = state.items.find((i) => i.id === action.id);
      if (existing) {
        return { items: state.items.map((i) => (i.id === action.id ? { ...i, qty: i.qty + 1 } : i)) };
      }
      return { items: [...state.items, { id: action.id, qty: 1 }] };
    }
    case "remove":
      return { items: state.items.filter((i) => i.id !== action.id) };
    case "clear":
      return { items: [] };
  }
}
// src/reducer-context/run-reducer.ts
import { cartReducer } from "./cartReducer.js";

let state = { items: [] };
console.log(state);

state = cartReducer(state, { type: "add", id: 1 });
console.log(state);

state = cartReducer(state, { type: "add", id: 1 });
console.log(state);

state = cartReducer(state, { type: "add", id: 2 });
console.log(state);

state = cartReducer(state, { type: "remove", id: 1 });
console.log(state);
$ npx tsx src/reducer-context/run-reducer.ts
{ items: [] }
{ items: [ { id: 1, qty: 1 } ] }
{ items: [ { id: 1, qty: 2 } ] }
{ items: [ { id: 1, qty: 2 }, { id: 2, qty: 1 } ] }
{ items: [ { id: 2, qty: 1 } ] }

Adding id: 1 twice doesn’t create two rows — the reducer finds the existing item and increments its qty instead. Every intermediate state is visible here with zero components, zero hooks, and zero test runner: just a function called five times in a row.

Sharing It Through Context

useReducer holds the state; createContext plus a custom hook makes it available to any descendant without passing state/dispatch down as props manually.

// src/reducer-context/CartContext.tsx
import { createContext, useContext, useReducer } from "react";
import { cartReducer } from "./cartReducer.js";

const CartContext = createContext(null);

export function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });
  return <CartContext value={{ state, dispatch }}>{children}</CartContext>;
}

export function useCart() {
  const value = useContext(CartContext);
  if (!value) throw new Error("useCart must be used inside <CartProvider>");
  return value;
}
// src/reducer-context/CartSummary.tsx
import { useCart } from "./CartContext.js";

export function CartSummary() {
  const { state, dispatch } = useCart();
  const totalItems = state.items.reduce((sum, i) => sum + i.qty, 0);

  return (
    <div>
      <p>Items in cart: {totalItems}</p>
      <button onClick={() => dispatch({ type: "add", id: 7 })}>Add mug</button>
      <button onClick={() => dispatch({ type: "clear" })}>Clear</button>
    </div>
  );
}
// src/reducer-context/CartSummary.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CartProvider } from "./CartContext.js";
import { CartSummary } from "./CartSummary.js";

it("adds the same item twice, then clears, through the reducer behind the context", async () => {
  const user = userEvent.setup();
  render(
    <CartProvider>
      <CartSummary />
    </CartProvider>
  );

  expect(screen.getByText("Items in cart: 0")).toBeInTheDocument();

  await user.click(screen.getByRole("button", { name: "Add mug" }));
  await user.click(screen.getByRole("button", { name: "Add mug" }));
  expect(screen.getByText("Items in cart: 2")).toBeInTheDocument();

  await user.click(screen.getByRole("button", { name: "Clear" }));
  expect(screen.getByText("Items in cart: 0")).toBeInTheDocument();
});
$ npx vitest run reducer-context/CartSummary.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

 ✓ src/reducer-context/CartSummary.test.tsx > adds the same item twice, then clears, through the reducer behind the context 159ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:37:44
   Duration  26.58s (environment 53%, setup 27%, import 17%, transform 1%, tests 1%)

CartSummary never imports cartReducer — it only knows about useCart(), which hides the context entirely. createContext(null)’s null is why useCart can check if (!value) throw ...: without a <CartProvider> above it, useContext returns exactly that fallback.

// src/reducer-context/CartSummary.outside.test.tsx
import { it, expect } from "vitest";
import { render } from "@testing-library/react";
import { CartSummary } from "./CartSummary.js";

it("throws when rendered outside CartProvider", () => {
  expect(() => render(<CartSummary />)).toThrow("useCart must be used inside <CartProvider>");
});
$ npx vitest run reducer-context/CartSummary.outside.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

 ✓ src/reducer-context/CartSummary.outside.test.tsx > throws when rendered outside CartProvider 19ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:38:35
   Duration  22.85s (environment 76%, setup 22%, transform 1%)

Practice

1. Add a "setQty" action that sets an item’s quantity directly, and test it with the reducer alone.

Solution
// src/reducer-context/practice/cartReducerSetQty.ts
type CartItem = { id: number; qty: number };
type CartState = { items: CartItem[] };
type CartAction =
  | { type: "add"; id: number }
  | { type: "remove"; id: number }
  | { type: "setQty"; id: number; qty: number }
  | { type: "clear" };

export function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "add": {
      const existing = state.items.find((i) => i.id === action.id);
      if (existing) {
        return { items: state.items.map((i) => (i.id === action.id ? { ...i, qty: i.qty + 1 } : i)) };
      }
      return { items: [...state.items, { id: action.id, qty: 1 }] };
    }
    case "remove":
      return { items: state.items.filter((i) => i.id !== action.id) };
    case "setQty":
      return { items: state.items.map((i) => (i.id === action.id ? { ...i, qty: action.qty } : i)) };
    case "clear":
      return { items: [] };
  }
}
// src/reducer-context/practice/run-set-qty.ts
import { cartReducer } from "./cartReducerSetQty.js";

let state = { items: [{ id: 1, qty: 1 }] };
console.log(state);

state = cartReducer(state, { type: "setQty", id: 1, qty: 5 });
console.log(state);
$ npx tsx src/reducer-context/practice/run-set-qty.ts
{ items: [ { id: 1, qty: 1 } ] }
{ items: [ { id: 1, qty: 5 } ] }

Same pattern as every other branch: no match on id leaves everything else unchanged, since .map returns a new item only for the one that matches.

2. Call cartReducer with an action type it doesn’t handle — one not in the CartAction union — and see what comes back.

Solution
// src/reducer-context/practice/run-unknown-action.ts
import { cartReducer } from "../cartReducer.js";

let state = { items: [{ id: 1, qty: 1 }] };
console.log(state);

// "undo" is not one of the reducer's known action types -- cast past
// the type system to see what happens to a plain JS reducer at runtime.
state = cartReducer(state, { type: "undo" } as any);
console.log(state);
$ npx tsx src/reducer-context/practice/run-unknown-action.ts
{ items: [ { id: 1, qty: 1 } ] }
undefined

The switch has no default case, so an unmatched action.type falls through to the end of the function with nothing returned — undefined, in plain JavaScript. TypeScript’s exhaustiveness checking would normally catch a missing case at compile time for a closed union; the as any cast in this example deliberately bypasses that to show what happens if an invalid action slips through anyway. A real app would see its whole cart disappear into undefined the next time anything reads state.items.

3. Render two CartSummary components under the same CartProvider and confirm they share one piece of state.

Solution
// src/reducer-context/practice/TwoConsumers.test.tsx
import { it, expect } from "vitest";
import { render, screen, getAllByText } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CartProvider } from "../CartContext.js";
import { CartSummary } from "../CartSummary.js";

it("two components under the same provider share one piece of state", async () => {
  const user = userEvent.setup();
  const { container } = render(
    <CartProvider>
      <CartSummary />
      <CartSummary />
    </CartProvider>
  );

  const [firstAdd] = screen.getAllByRole("button", { name: "Add mug" });
  await user.click(firstAdd);

  expect(getAllByText(container, "Items in cart: 1")).toHaveLength(2);
});
$ npx vitest run reducer-context/practice/TwoConsumers.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

 ✓ src/reducer-context/practice/TwoConsumers.test.tsx > two components under the same provider share one piece of state 199ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:39:06
   Duration  25.11s (environment 70%, setup 19%, import 9%, transform 1%, tests 1%)

Clicking the first CartSummary’s button updates both — there’s exactly one useReducer call, inside the single CartProvider, and both components read it through the same useCart() call.

Frequently Asked Questions

Why test a reducer directly instead of through a rendered component?
A reducer written as (state, action) => newState is a plain function with no dependency on React at all — call it with a state and an action, check what it returns. There's no need to render anything or click a button to exercise every branch of its logic.
Why does useContext need a Provider to work?
createContext(null) sets null as the fallback value returned when no matching Provider is found above the component in the tree. Wrapping useContext in a custom hook that checks for that fallback and throws turns a confusing "something is undefined" bug far from its cause into an error at the exact place the mistake was made.
When should I reach for useReducer instead of several useState calls?
When one update needs to change multiple related pieces of state together in a single, well-defined way — adding an item that might also need to update a total, for instance. A reducer keeps all of those transitions in one place, described by name (the action type), instead of spread across several setState calls that each need to stay in sync by hand.
Does useContext re-render every consumer whenever the context value changes?
Yes — every component that calls useContext for that context re-renders whenever the value passed to the Provider changes, even if that component only reads part of it. Splitting a large context into smaller ones, each covering values that change together, limits how far a given update has to re-render.