Context API In Depth
Context propagates through the fiber tree, and every consumer re-renders when the value changes. See one cart update re-render the whole app through a monolithic context, then split contexts so only the right components update.
🎯 What you'll learn
- Context objects, providers and the fiber stack
- How provider updates reach consumers
- Splitting contexts and the state/dispatch split
- Selector-style subscriptions, use() and server components
📚 Before you start
Prerequisite: frontend/react/hooks/03-usecontext (the basic API, the default value, and the "all consumers re-render" gotcha). This file assumes you know createContext / Provider / useContext and goes after the mechanism, the composition patterns, and the escape hatches.
1A Context Object Is Just a Pair of Fibers and a Stack
createContext(defaultValue) returns a plain object. Nothing magic lives in it:
const ThemeContext = createContext<"light" | "dark">("light");
console.log(Object.keys(ThemeContext));
// ["$$typeof", "_currentValue", "_currentValue2", "Provider", "Consumer", ...]The two fields that matter:
_currentValue— a single mutable slot holding the value currently in scope during rendering. It is not per-component and not per-tree; it is one variable.Provider— a special element type React recognises. Rendering it does two things: it pushes the old_currentValueonto an internal stack and writes the new value into_currentValue; when React finishes that subtree it pops the stack back.
That push/pop is exactly why "nearest provider wins" works, and why it is a render-time lexical concept, not a runtime lookup:
<ThemeContext.Provider value="dark"> {/* push "light", _currentValue = "dark" */}
<A /> {/* reads "dark" */}
<ThemeContext.Provider value="light"> {/* push "dark", _currentValue = "light" */}
<B /> {/* reads "light" */}
</ThemeContext.Provider> {/* pop -> _currentValue = "dark" */}
<C /> {/* reads "dark" */}
</ThemeContext.Provider> {/* pop -> _currentValue = "light" (the default) */}useContext(Ctx) does not climb the tree at call time. It reads Ctx._currentValue — which is already correct because React is depth-first rendering and the provider pushed the value on the way down.
2What useContext Records on the Fiber
Reading a context is a subscription, and the subscription is stored on the reading fiber:
function ThemedButton() {
const theme = useContext(ThemeContext); // <- registers a dependency
return <button className={theme} />;
}During that call React appends an entry to fiber.dependencies.firstContext, a singly-linked list of { context, memoizedValue, next }. The list is rebuilt from scratch on every render of that component — which is why conditional reads are technically fine for correctness even though useContext is a hook (it has no hook-slot; it doesn't participate in the hooks linked list at all). See frontend/react/hooks/01-usestate for the hooks list this is deliberately separate from.
Under the HoodHow a Provider Update Reaches Consumers▾
When a Provider fiber begins work, React compares the new value prop to the old one with Object.is:
- Equal → nothing propagates. React can bail out of the whole subtree if nothing else scheduled work there.
- Not equal → React calls
propagateContextChange, which does a manual depth-first walk of the provider's entire subtree, looking at every fiber'sdependencieslist.
For each fiber whose dependency list contains this exact context object, React:
- schedules an update on that fiber (OR-s the current render lane into
fiber.lanes), and - walks from that fiber back up to the provider, OR-ing the lane into every ancestor's
childLanes.
That second step is the whole story behind the most surprising context behaviour:
const Sidebar = memo(function Sidebar() {
return <ThemedButton />; // ThemedButton calls useContext(ThemeContext)
});
// <Provider value={newObject}><Sidebar /></Provider>
// Sidebar's props did not change. Sidebar still gets re-rendered.memo (and PureComponent, and the "props are identical" fast path) works by bailing out — returning the previous children fibers untouched. But the bailout check is gated on childLanes: React refuses to bail out if any descendant has pending work. propagateContextChange already stamped childLanes on Sidebar, so React must descend through it to reach ThemedButton. memo cannot stop context propagation. It only avoids re-executing Sidebar's function body in some paths, and even that is not guaranteed — the common outcome is that Sidebar's function body runs again.
Three consequences that follow directly:
- The comparison is
Object.ison the wholevalueprop.value={{ user, theme }}allocates a new object each render, so it is never equal, so every consumer in the subtree re-renders on every provider render. This is the memoize-the-value rule fromhooks/03-usecontext, now with a reason attached. - There is no partial subscription. React matches on the context object identity, not on which fields you read. A consumer that reads only
value.themeis indistinguishable, at the fiber level, from one that readsvalue.user. - The walk is O(subtree size), not O(consumers). A provider high in the tree with one deep consumer still pays a full subtree scan on every value change. React short-circuits subtrees containing another provider for the same context (they shadow it), but not otherwise.
4Splitting Contexts: The Only Real Fix
Since React matches on context identity, the way to get finer-grained subscriptions is to have more context identities.
// One context per independent concern
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>("light");
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<Theme>("light");
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
</UserContext.Provider>
);
}Now a setTheme call propagates only through ThemeContext's dependency matches. UserContext's value prop is still the same user reference, so its provider bails out immediately.
Note there is no useMemo here at all — user and theme are already stable references straight out of useState. Memoizing is only necessary when you build a composite object; the better move is usually to not build one.
5The State/Dispatch Split
A very common shape: a reducer whose state changes constantly but whose dispatch never changes. Putting both in one context forces every dispatcher-only component (buttons, forms) to re-render on every state change.
const TodoStateContext = createContext<Todo[]>([]);
const TodoDispatchContext = createContext<React.Dispatch<Action>>(() => {});
function TodoProvider({ children }: { children: React.ReactNode }) {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<TodoDispatchContext.Provider value={dispatch}>
<TodoStateContext.Provider value={todos}>{children}</TodoStateContext.Provider>
</TodoDispatchContext.Provider>
);
}
// A button that only dispatches never re-renders when todos change:
function AddButton() {
const dispatch = useContext(TodoDispatchContext);
return <button onClick={() => dispatch({ type: "add" })}>Add</button>;
}dispatch from useReducer is guaranteed stable for the lifetime of the component (see frontend/react/hooks/04-usereducer), so TodoDispatchContext's provider bails out on every render. Order the providers with the stable one outermost so a state change re-renders as little provider machinery as possible.
6Custom Hook + Guard: The Standard Packaging
Exporting the raw context object leaks the "you forgot the Provider" failure mode into every call site. Export a hook instead, and use undefined-as-default to detect a missing provider:
const AuthContext = createContext<AuthValue | undefined>(undefined);
export function useAuth(): AuthValue {
const ctx = useContext(AuthContext);
if (ctx === undefined) {
throw new Error("useAuth must be used inside <AuthProvider>");
}
return ctx; // TypeScript now narrows to AuthValue — no `?.` at call sites
}Two wins at once: a real error message instead of a silent undefined.user crash three components away, and the non-null narrowing that makes the consumer code clean.
7Selector-Style Subscriptions with useSyncExternalStore
If you genuinely need field-level subscriptions inside one provider, stop using context to carry changing values and use it to carry an immutable store handle:
type Store<T> = {
getSnapshot: () => T;
subscribe: (listener: () => void) => () => void;
};
const StoreContext = createContext<Store<AppState> | null>(null);
export function useSelector<S>(selector: (s: AppState) => S): S {
const store = useContext(StoreContext)!;
return useSyncExternalStore(
store.subscribe,
() => selector(store.getSnapshot()), // re-read + re-select on every notify
);
}The context value is the store object, created once, so its identity never changes — propagateContextChange never fires. Updates flow through subscribe, and useSyncExternalStore re-renders a component only when its selected slice changes by Object.is. This is precisely the architecture Redux's react-redux and Zustand use; see frontend/ecosystem/10-redux and frontend/ecosystem/12-zustand.
8Reading Context Conditionally with use
use(Context) (React 19) reads a context but, unlike useContext, may be called conditionally and inside loops:
function Heading({ plain }: { plain: boolean }) {
if (plain) return <h1>Title</h1>;
const theme = use(ThemeContext); // legal — `use` is not slot-based
return <h1 className={theme}>Title</h1>;
}The subscription is registered when the call actually executes, so on renders where plain is true this fiber has no ThemeContext dependency and is skipped by propagation entirely. Useful for genuinely optional reads; it is not a performance trick for the common case.
9Context and Server Components
createContext is a client-only API. A React Server Component has no fiber tree on the server for the provider stack to live in, so useContext and Provider both throw there. The pattern is to put the provider in a "use client" file and render it from a server component as a wrapper around children:
// providers.tsx
"use client";
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
// app/layout.tsx — a Server Component
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html><body><Providers>{children}</Providers></body></html>;
}children passed through a client component stays a server-rendered subtree — it does not get pulled into the client bundle. Details in frontend/ecosystem/03-nextjs_server_components.
⚠️ Common Pitfalls
- Inline object/array/function as
value.value={{ user, logout }}andvalue={[a, b]}allocate a fresh reference every render, soObject.isalways fails and every consumer in the subtree re-renders. Why it bites: the provider often re-renders for reasons unrelated to the context (a parent state change), so the cost appears with no visible cause. - Reaching for
memoto stop context re-renders. It cannot work —propagateContextChangemarkschildLaneson the memo boundary, which is exactly the condition that forbids a bailout (Section 3). The fix is splitting contexts or moving to a store + selector. - One "AppContext" holding everything. Every unrelated field change re-renders every consumer. Why: matching is on context identity, so
settings.fontSizeanduser.nameare the same subscription. - Using
createContext(defaultValue)as the real value. The default is used only when there is no matching provider anywhere above. A provider that rendersvalue={undefined}gives youundefined, not the default. Why it bites: a typo in the provider surfaces as a mysteriousundefinedrather than a fallback. - Putting a provider inside a component that re-mounts. If the provider's parent conditionally renders it (
{open && <Provider>...}), unmounting destroys all state held in the provider. Why: the state lives in the provider's fiber, and unmounting frees the fiber. - Creating a context inside a component body.
const Ctx = createContext(...)inside render makes a new context object every render; consumers subscribe to old identities and read the default value forever. Always module scope. - Assuming context is a state manager. Context is a transport mechanism — it distributes a value; it does not store, batch, or diff anything. The state still lives in whatever
useState/useReducer/external store you put above it. - Deeply nested provider pyramids. Ten nested providers means ten fibers on every render pass and ten stack push/pops. Harmless at small scale, but flatten with a composed
Providerscomponent and keep the stable ones outermost. - Mutating the value object instead of replacing it.
value.count++keeps the same reference,Object.ispasses, and no consumer updates. Why: propagation is identity-based, and React never deep-compares.
📋 Quick Reference
| Need | Do this |
|---|---|
| Distribute a rarely-changing value | One context, value straight from useState |
| Distribute several independent values | One context per concern, nested providers |
| Reducer state + dispatch | Two contexts; dispatch provider outermost |
| Field-level subscriptions in one store | Context carries a stable store handle + useSyncExternalStore |
| Detect a missing Provider | createContext<T \| undefined>(undefined) + throwing custom hook |
| Optional / conditional read | use(Context) instead of useContext(Context) |
| Provider in a Next.js App Router tree | "use client" provider file, wrap {children} from the server layout |
| Mechanism | Fact |
|---|---|
| Value lookup | Ctx._currentValue, pushed/popped on a stack during render |
| Subscription storage | fiber.dependencies.firstContext linked list, rebuilt every render |
| Change detection | Object.is(newValue, oldValue) on the value prop |
| Propagation | Depth-first scan of the provider subtree (propagateContextChange) |
Why memo doesn't help | Consumers' lanes are OR-ed into every ancestor's childLanes, forbidding bailout |
| Cost of a change | O(size of provider subtree), regardless of consumer count |
Next: frontend/react/advanced/02-state_management_patterns builds on this to compare context, reducer-per-feature, and external stores.
🧠 Key takeaways
- Providers push values onto a context stack during render;
useContextregisters a dependency on the fiber. - A value change schedules updates for all consumers below, bypassing memo.
- Split contexts by concern and change frequency; separate state from dispatch; memoize values.
- For selectors use useSyncExternalStore stores;
use(Context)can be called conditionally; server components can't read context.
🎤 Interview Q&A
Q1How do you structure context for a medium-sized app?core▾
Several focused contexts (auth, theme, feature flags) rather than one giant context; colocate providers near consumers; separate state and dispatch; memoize values.
- Why focused contexts? Fewer unnecessary re-renders and clearer ownership.
Q2How can you make context consumers re-render less?core▾
Split contexts, memoize provider values, pass stable dispatch, and use selector libraries (use-context-selector) or external stores with useSyncExternalStore.
- What's useSyncExternalStore? A hook for subscribing to external stores safely in concurrent rendering.
Q3Context vs Redux/Zustand?core▾
Context is great for low-frequency global values and dependency injection. Stores offer selective subscriptions, devtools, middleware and better performance for frequently updated shared state.
- Can they be combined? Yes, e.g. context to provide a store instance per subtree.
Q4How do you use context for dependency injection?senior▾
Provide services (API clients, analytics, feature flag clients) via context so components depend on interfaces, easily swapped in tests or storybook.
- Why is this testable? Tests wrap components with providers supplying fakes.
Q5What changes in React 19 for context?core▾
<Context> can be used directly as a provider (instead of <Context.Provider>), and use(Context) can read context conditionally.
- What does use() also do? Reads promises inside Suspense.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.