Engineering Notes
Home/Frontend/React · Advanced/Context API In Depth
Frontend · React · Advanced

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.

🎨 Frontend● Advanced⏱ 10 min read▶ 1 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 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

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:

tsx
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 _currentValue onto 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:

tsx
<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:

tsx
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▾
Provider re-render walks its subtree, matches fiber dependency lists, and marks childLanes on every ancestor
Provider re-render walks its subtree, matches fiber dependency lists, and marks childLanes on every ancestor

When a Provider fiber begins work, React compares the new value prop to the old one with Object.is:

  1. Equal → nothing propagates. React can bail out of the whole subtree if nothing else scheduled work there.
  2. Not equal → React calls propagateContextChange, which does a manual depth-first walk of the provider's entire subtree, looking at every fiber's dependencies list.

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:

tsx
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.is on the whole value prop. 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 from hooks/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.theme is indistinguishable, at the fiber level, from one that reads value.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.

tsx
// 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.

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
// 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

  1. Inline object/array/function as value. value={{ user, logout }} and value={[a, b]} allocate a fresh reference every render, so Object.is always 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.
  2. Reaching for memo to stop context re-renders. It cannot work — propagateContextChange marks childLanes on 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.
  3. One "AppContext" holding everything. Every unrelated field change re-renders every consumer. Why: matching is on context identity, so settings.fontSize and user.name are the same subscription.
  4. Using createContext(defaultValue) as the real value. The default is used only when there is no matching provider anywhere above. A provider that renders value={undefined} gives you undefined, not the default. Why it bites: a typo in the provider surfaces as a mysterious undefined rather than a fallback.
  5. 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.
  6. 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.
  7. 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.
  8. 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 Providers component and keep the stable ones outermost.
  9. Mutating the value object instead of replacing it. value.count++ keeps the same reference, Object.is passes, and no consumer updates. Why: propagation is identity-based, and React never deep-compares.

📋 Quick Reference

NeedDo this
Distribute a rarely-changing valueOne context, value straight from useState
Distribute several independent valuesOne context per concern, nested providers
Reducer state + dispatchTwo contexts; dispatch provider outermost
Field-level subscriptions in one storeContext carries a stable store handle + useSyncExternalStore
Detect a missing ProvidercreateContext<T \| undefined>(undefined) + throwing custom hook
Optional / conditional readuse(Context) instead of useContext(Context)
Provider in a Next.js App Router tree"use client" provider file, wrap {children} from the server layout
MechanismFact
Value lookupCtx._currentValue, pushed/popped on a stack during render
Subscription storagefiber.dependencies.firstContext linked list, rebuilt every render
Change detectionObject.is(newValue, oldValue) on the value prop
PropagationDepth-first scan of the provider subtree (propagateContextChange)
Why memo doesn't helpConsumers' lanes are OR-ed into every ancestor's childLanes, forbidding bailout
Cost of a changeO(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; useContext registers 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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. Why does a monolithic context hurt performance?
All-or-nothing.
2. Why put dispatch in its own context?
Change frequency.
3. Which hook enables selector-based subscriptions to external stores?
Tearing-safe subscriptions.
4. Can Server Components read React context?
Provide it in a client component.