React120 min total · 17 parts
React Fundamentals: Components, Hooks, and the Virtual DOM
Part 12 of 17 · ~4 min
The Context API
Chapter 2 left onStageChange threaded through two components that never call it. Since then the tree has grown a second passenger: the current reviewer.
{ id: "u_12", name: "Dana Okonkwo", canReject: true, isAdmin: false }
The row's action buttons need it — a reviewer without canReject should not see a Reject button — and it is needed again in the drawer's header, and again in the review form to stamp the note. Three different leaves, at three different depths, each reachable only by passing the object through every component in between.
Context lets a value skip the intermediate layers. A provider publishes it; any descendant, at any depth, reads it directly.
const ReviewerContext = createContext(null);
function App() {
return (
<ReviewerContext.Provider value={currentReviewer}>
<Dashboard />
</ReviewerContext.Provider>
);
}
function CandidateTable({ candidates }) {
// knows nothing about reviewers, and no longer has to
return candidates.map((c) => <CandidateRow key={c.id} candidate={c} />);
}
function RowActions({ candidate }) {
const reviewer = useContext(ReviewerContext); // straight from the provider
if (!reviewer.canReject) return null;
return <button onClick={() => reject(candidate.id)}>Reject</button>;
}
CandidateTable and CandidateRow lost a prop they never wanted. In React 19 the provider can be written as <ReviewerContext value={currentReviewer}> directly, without .Provider; the longer form is what you will see in every existing codebase.
The value passed to createContext(null) is a default, used only when a component reads the context with no provider above it anywhere. That is rarer than people expect, and a null default plus a small wrapper hook is the more useful pattern, because it turns "I forgot the provider" into a clear error instead of a confusing undefined:
function useReviewer() {
const reviewer = useContext(ReviewerContext);
if (!reviewer) throw new Error("useReviewer must be used inside <ReviewerContext.Provider>");
return reviewer;
}
The performance gotcha
Every component that calls useContext(SomeContext) re-renders when that context's value changes — with no regard for whether it reads the part that changed, and with no way to opt out.
That sentence is the entire reason the obvious design fails. The obvious design is one context for the whole screen:
// don't
<DashboardContext.Provider value={{ reviewer, filters, candidates, selectedId }}>
Now typing one character into the filter box changes filters, which changes the context value, which re-renders every consumer in the tree — including all eight hundred rows, which are reading the context only for reviewer, which has not changed since the page loaded. The memoization from chapter 9 does not save you either: React.memo does not prevent a re-render caused by a context change. Memo compares props, and context does not arrive as a prop.
There is a second, quieter version of the same bug, which is worth checking for even in a well-split context:
<ReviewerContext.Provider value={{ id, name, canReject }}> // new object every render
That object literal is rebuilt on every render of the provider's parent, so the context value is never equal to last time and every consumer re-renders on every parent render, forever — even though nothing about the reviewer changed. Memoize it:
const reviewer = useMemo(() => ({ id, name, canReject }), [id, name, canReject]);
The primary fix is to split by change frequency rather than by subject matter. ReviewerContext changes at most once a session. FiltersContext changes on every keystroke. They have no business sharing a provider:
<ReviewerContext.Provider value={reviewer}>
<FiltersContext.Provider value={filters}>
<FiltersDispatchContext.Provider value={dispatch}>
<Dashboard />
</FiltersDispatchContext.Provider>
</FiltersContext.Provider>
</ReviewerContext.Provider>
That third one is a pattern worth knowing. Splitting state from the dispatcher means a component that only ever changes filters — a "Clear filters" button, a column header — reads FiltersDispatchContext, which never changes, and so never re-renders when the filters do. Pairs naturally with the last chapter, since dispatch is stable for free.
Two boundaries on what Context is for. It is a way to pass a value down, not a way to store one — the state still lives in a useState or useReducer somewhere, and Context only moves it. And it has no concept of subscribing to a slice: any change re-renders every consumer. When you genuinely need slice-level subscriptions in a hot path, that is what Zustand, Jotai and Redux are built for, and useSyncExternalStore is the hook React provides for integrating an external store correctly.