React · lesson 9 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5useReducer 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
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.