Context with a reducer
Context + useReducer is the built-in store for moderate cross-cutting state with non-trivial transitions; split state and dispatch into two contexts so dispatch — whose identity is stable — never re-renders consumers; fails for server state or huge/high-frequency state.
You have shared state that more than a few distant components touch — a cart, the signed-in user, a theme with non-trivial rules — and its updates aren’t single setState calls but real transitions: “add item, but if it already exists, bump the quantity”; “log in”, “log out”, “token refreshed”. A plain useState in context handles the reading, but the transition logic ends up smeared across every component that mutates it. You don’t want a third-party store for this; it isn’t that big.
The built-in answer is context + useReducer: the reducer owns every transition in one place, and context delivers state and dispatch to the tree. The non-obvious part — the part that separates a junior wiring from a senior one — is how you hand those two things out, because doing it naively re-renders far more than it should.
After this lesson you can build cross-cutting state (a cart or auth flow) as a reducer exposed through context; explain why you put state and dispatch in two separate contexts; explain why the dispatch context never causes re-renders (dispatch identity is stable across renders); wrap both in a single provider component with typed hooks; and name the pattern’s failure mode — reaching for it when the data is really server state (use a query library) or is huge / high-frequency (use an external store with selectors).
A reducer puts every transition in one place — so the state shape and its rules live together, not scattered across consumers. With useState, each component that mutates the cart re-implements “does this item exist? then increment, else push”. A reducer makes the transition a named, exhaustive function; components just announce intent with dispatch.
type CartItem = { id: string; qty: number };
type CartState = { items: CartItem[] };
type CartAction =
| { type: "add"; id: string }
| { type: "remove"; id: string }
| { type: "clear" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "add": {
const existing = state.items.find((i) => i.id === action.id);
const items = existing
? state.items.map((i) => (i.id === action.id ? { ...i, qty: i.qty + 1 } : i))
: [...state.items, { id: action.id, qty: 1 }];
return { items };
}
case "remove":
return { items: state.items.filter((i) => i.id !== action.id) };
case "clear":
return { items: [] };
}
}The reducer is a pure function with no React in it — trivially unit-testable, and the discriminated union makes an unhandled action a type error. The “add bumps quantity” rule now exists exactly once.
Hand out state and dispatch through two separate contexts — not one object. The naive version is <CartContext.Provider value={{ state, dispatch }}>. The problem: that value object is a new reference on every render, so every consumer re-renders even if it only ever calls dispatch. Splitting them lets a component subscribe to exactly what it needs.
const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext = createContext<React.Dispatch<CartAction> | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}A button that only adds items reads the dispatch context and never the state context — so when the cart changes, that button does not re-render.
The dispatch context never triggers re-renders, because useReducer returns a dispatch whose identity is stable for the component’s whole lifetime. React guarantees dispatch (like a setState updater) is referentially stable across renders. A context only re-renders its consumers when the provider’s value changes by Object.is. Since the dispatch reference never changes, the dispatch context’s value never changes, so its consumers never re-render from it. Only the state context’s value changes per update, and only its readers re-render.
export function useCartDispatch() {
const ctx = useContext(CartDispatchContext);
if (!ctx) throw new Error("useCartDispatch must be used inside <CartProvider>");
return ctx; // stable forever — safe to put in deps, safe to pass down
}
export function useCart() {
const ctx = useContext(CartStateContext);
if (!ctx) throw new Error("useCart must be used inside <CartProvider>");
return ctx;
}This is the whole efficiency trick: the writers (action triggers) are decoupled from the readers (state displays). In a single-context design you’d reach for useMemo on the value and memo on children to claw this back; the split gives it to you structurally, for free.
Know the failure mode: this pattern is for moderate client state with real transitions — not server state, and not huge / high-frequency state. Two misuses sink it. First, putting server state here (the product catalog, the user’s orders) means you hand-roll caching, refetching, staleness, and dedup that a query library already solves — context + reducer has no concept of “this data came from the network and can go stale”. Second, when the state is large or updates many times per second (a live cursor, a big editor document, per-keystroke form state read by hundreds of nodes), even the split-context approach re-renders every state reader on every change, because context can’t do selective subscriptions.
// WRONG: server state shoved into a reducer/context
dispatch({ type: "ordersLoaded", orders }); // who refetches? when is this stale?
// → use TanStack Query / SWR: useQuery(['orders', userId], fetchOrders)
// WRONG: high-frequency, selector-needing state in context
// every keystroke re-renders all 200 fields that read the form context
// → use an external store (Zustand/Redux + selectors, or useSyncExternalStore)Context + reducer sits in the middle: bigger than useState lifted one level, smaller than a store. Match it to that band.
An auth flow: from a single context object to a split-context reducer — and watching the re-renders drop. Start with the naive version many codebases ship.
// BEFORE: one context, one object value — every consumer re-renders on any change
const AuthContext = createContext<{
user: User | null;
login: (u: User) => void;
logout: () => void;
} | null>(null);
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// new object every render → all consumers re-render even on unrelated parent renders
const value = { user, login: setUser, logout: () => setUser(null) };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}Two problems: the transition logic (“token refreshed” should keep the user but swap the token) has nowhere to live except more ad-hoc setters, and value is unstable so a <LoginButton> that never reads user still re-renders whenever user changes. Now the reducer + split-context version:
type AuthState = { user: User | null; token: string | null };
type AuthAction =
| { type: "login"; user: User; token: string }
| { type: "logout" }
| { type: "refreshed"; token: string };
function authReducer(s: AuthState, a: AuthAction): AuthState {
switch (a.type) {
case "login": return { user: a.user, token: a.token };
case "logout": return { user: null, token: null };
case "refreshed": return { ...s, token: a.token }; // keep user, swap token
}
}
const AuthStateContext = createContext<AuthState | null>(null);
const AuthDispatchContext = createContext<React.Dispatch<AuthAction> | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(authReducer, { user: null, token: null });
return (
<AuthStateContext.Provider value={state}>
<AuthDispatchContext.Provider value={dispatch}>{children}</AuthDispatchContext.Provider>
</AuthStateContext.Provider>
);
}
// <LoginButton> calls useAuthDispatch() only → does NOT re-render when user changes
// <Avatar> calls useAuth() (state) → re-renders only when state actually changesThe “refreshed” transition now lives in one tested place, and the button that only dispatches is structurally insulated from state changes. Note what we did not do: the user’s profile data and orders are server state — those stay in a query library, not this reducer. This context holds only the session identity and the transitions over it.
▸Why this works
Why split into two contexts instead of one context plus useMemo on the value and React.memo on every consumer? Both can work, but they pay for the same outcome differently. The single-context route requires you to remember to memoize the value object and wrap consumers in memo, and one forgotten useMemo silently reintroduces the every-render churn. The split is structural: a component physically cannot subscribe to dispatch-and-also-state unless it reads both contexts, so the cheap case (write-only components) is cheap by construction, not by vigilance. Less to get wrong is the senior reason.
▸Common mistake
The tempting overreach is treating context + reducer as “Redux but built in” and pouring everything into it — server data, derived values, high-frequency UI state. It is not a general store: it has no selectors (a state reader re-renders on any state change, not just the slice it uses) and no async/caching model. The instinct to ask before adding a slice: is this data the client’s own session/UI state, of moderate size, with bounded update frequency? If it’s network-owned data, it belongs in a query cache; if it’s large or updates constantly and components need slices, it belongs in a store with selectors. Context + reducer is the middle band, not the universal answer.
You expose a cart reducer via two contexts: CartStateContext (value = state) and CartDispatchContext (value = dispatch). A <AddToCartButton> calls only useCartDispatch(). The cart changes when an item is added. Does AddToCartButton re-render from the context, and why?
Context + useReducer is React’s built-in answer for moderate cross-cutting client state with non-trivial transitions — bigger than one lifted useState, smaller than a third-party store. The reducer puts every transition in one pure, testable place; context delivers it to the tree. The senior move is to split state and dispatch into two contexts: because useReducer’s dispatch has a stable identity across renders, the dispatch context’s value never changes, so write-only components reading only dispatch never re-render — the readers and writers are decoupled structurally, with no useMemo/memo vigilance required. Wrap both in one provider with typed useCart() / useCartDispatch() hooks that throw outside the provider. And know the band: this pattern fails when the data is really server state (use a query library — it has caching, staleness, refetch) or is huge / high-frequency and needs slices (use an external store with selectors). Match it to the middle, and it’s the cleanest no-dependency store you can build.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.