Skip to content
DevDepth
← Back to all articles

In-Depth Article

How useContext Works in React: Context Storage, Provider Stack, and Propagation

Learn how useContext works under the hood in React: mutable current values, the Provider stack, Fiber dependency tracking, Object.is change detection, and context propagation.

Published: Updated: 9 min readreact-internals

useContext is often explained as "a way to avoid prop drilling." That is true at the API level, but it hides the more interesting part:

useContext feels like a tree lookup, but React makes it fast by combining a mutable current-value cursor, an internal stack for nested providers, and a dependency list on each consumer Fiber.

Once that clicks, several React behaviors become much easier to reason about:

  • why reading context is cheap during render
  • how nested providers override each other correctly
  • why a changed provider can still update consumers behind React.memo
  • why plain useContext can cause broader re-renders than people expect

This article focuses on modern React internals, especially ReactContext.js and ReactFiberNewContext.js. If you want nearby context first, this pairs well with how React updates move from setState to a DOM update, how React priority works with lanes and scheduling, and why Hooks must keep a stable call order.

1. Start with the most important correction: useContext is not doing a parent search every time

From the outside, useContext(ThemeContext) looks like React might climb the component tree until it finds the nearest provider.

That is a natural guess, but it is not the best mental model for what actually happens during render.

The more accurate version is:

  1. Providers update a mutable "current value" field as React enters and exits their subtrees
  2. useContext reads that current value in O(1)
  3. React also records that the current consumer Fiber depends on that context

So the expensive part is not the read itself. The read is cheap. The more interesting work happens when providers change and React has to notify the right consumers.

2. What createContext actually creates

When you write this:

const ThemeContext = createContext("light");

React creates a context object.

At a simplified level, the object includes fields like these:

const context = {
  $$typeof: REACT_CONTEXT_TYPE,
  _currentValue: defaultValue,
  _currentValue2: defaultValue,
  Provider: context,
  Consumer: { _context: context },
};

The public API treats this object as a token you pass to Provider and useContext. Internally, React also uses it as mutable storage for the current render-time value.

Why there are two current-value fields

If you have only seen simplified examples, _currentValue may look like the whole story.

In current source, React also keeps _currentValue2 to support the primary/secondary renderer split. You do not need to memorize that detail for day-to-day coding, but it helps explain why source snippets sometimes show two value fields instead of one.

So the practical summary is:

A context object is not just a label. React uses it as the shared storage location for the "current value" during render.

3. How Providers make nested Context work: push on the way down, pop on the way back up

The next question is the important one:

If the context object has a mutable current-value field, how can React support nested providers of the same context?

For example:

<ThemeContext.Provider value="light">
  <Sidebar />
  <ThemeContext.Provider value="dark">
    <Preview />
  </ThemeContext.Provider>
</ThemeContext.Provider>

If you are using React 19's shorthand provider syntax, the same idea also applies to:

<ThemeContext value="light">
  <Sidebar />
  <ThemeContext value="dark">
    <Preview />
  </ThemeContext>
</ThemeContext>

How can Sidebar see "light" while Preview sees "dark" if there is only one mutable current slot?

The answer is an internal stack.

What happens when React enters a provider

During the render walk, when React begins work on a context provider, it calls pushProvider(...).

Conceptually, that does two things:

  1. save the old current value on a stack
  2. replace the current value with the provider's new value

A simplified mental model looks like this:

push(valueCursor, context._currentValue);
context._currentValue = nextValue;

Now any child rendered under that provider reads the new value.

What happens when React exits that provider subtree

When React completes or unwinds out of that provider subtree, it calls popProvider(...).

Conceptually:

context._currentValue = valueCursor.current;
pop(valueCursor);

That restores the previous value so sibling branches outside the nested provider keep seeing the correct outer value.

Why depth-first traversal makes this elegant

React renders the tree in a depth-first way. That means push/pop behavior lines up perfectly with entering and leaving subtrees.

So the mental model is:

  • entering a provider shadows the previous context value
  • leaving that subtree restores the previous one

That is why nested providers work without useContext needing to search upward dynamically on every read.

4. useContext itself is basically readContext(...)

When a function component calls:

const theme = useContext(ThemeContext);

React eventually funnels that into readContext(...).

The first part is straightforward:

  • React reads the current value from the context object
  • for the active renderer, that means _currentValue or _currentValue2

So at the raw value level, useContext is very cheap.

That is why people sometimes describe it as "reading a global variable." That is not totally wrong, but it is only half the story.

The other half is dependency tracking.

5. Reading context also records a dependency on the current Fiber

If useContext only returned the current value, React would not know who to update later.

So readContextForConsumer(...) does something else:

  1. it reads the current value
  2. it creates a context dependency record
  3. it appends that record to the currently rendering Fiber's context-dependency list

In current source, the record shape is conceptually like this:

const contextItem = {
  context: ThemeContext,
  memoizedValue: value,
  next: null,
};

And the Fiber's dependencies field conceptually becomes:

fiber.dependencies = {
  lanes: NoLanes,
  firstContext: contextItem,
};

If the component reads multiple contexts, React appends more nodes to that linked list.

That means useContext is not just:

give me the current value

It is also:

remember that this Fiber depends on this context, along with the value it saw during this render

That dependency list is the reason provider changes can later find the right consumers.

6. Why useContext can only be read while React is rendering

This detail falls directly out of the implementation.

readContext(...) relies on currentlyRenderingFiber and the active provider stack being in a valid render-time state.

That is why React warns if context is read in the wrong place.

In practical terms:

  • reading context in the function body during render is fine
  • reading it inside render-driven logic that React controls is fine
  • trying to read it from places that are not part of the current render pass is not the intended model

So if you have ever seen warnings about reading context inside the wrong Hook callback, this is the deeper reason:

Context reads are tied to render-time bookkeeping, not just value access.

7. How provider updates decide whether anything changed

Suppose a provider renders again:

<ThemeContext.Provider value={theme}>
  {children}
</ThemeContext.Provider>

React does not automatically assume that every render changed the context.

When React compares the old provider value to the new provider value, it uses Object.is.

That means:

  • if Object.is(oldValue, newValue) is true, React can treat the provider value as unchanged
  • if it is false, React needs to propagate the context change

That Object.is check is extremely important because it explains many real-world behaviors:

  • primitive values often behave as expected
  • object literals can trigger updates every render if their identity keeps changing
  • memoizing a provider value can prevent unnecessary propagation when the meaningful inputs did not change

8. How propagation works when the provider value changes

When React detects a changed provider value, it has to notify consumers below that provider.

This is where the update cost of Context shows up.

The simplified story is:

  1. React detects that one or more provider values changed
  2. it propagates those changed contexts through the relevant subtree
  3. it checks each visited Fiber's context dependency list
  4. if a dependency matches the changed context, React marks work on that consumer Fiber
  5. it also updates the parent path so the scheduler can reach that consumer in a future render

In the source, this lives around functions such as:

  • propagateContextChanges(...)
  • scheduleContextWorkOnParentPath(...)

The important implementation detail is that React is not broadcasting to every component in the app. It is walking the affected subtree and looking specifically for Fibers whose dependencies.firstContext list includes the changed context.

A small but important nuance: modern propagation is optimized

Many older explanations say:

"Provider change means React blindly walks the entire subtree every time."

That is too blunt for modern React.

Current React has lazy propagation optimizations and bailout-related flags that reduce some unnecessary repeated work.

But the higher-level performance lesson is still true:

plain useContext is coarse-grained. If the provider value changes by identity, every consumer of that context may be scheduled, even if each consumer only cares about a small part of the object.

9. Why context updates can still reach consumers behind React.memo

This is another place people get surprised.

You might expect a memoized parent to block updates from reaching a context consumer below it.

But context propagation is not the same thing as "the parent re-rendered, so the child re-rendered."

When React finds a matching context dependency during propagation, it:

  • marks lanes on the consumer Fiber
  • updates the relevant ancestor path with child-lane information

So the consumer can still be revisited even if some ancestor component would otherwise bail out on props alone.

That is the practical meaning of the common React docs caveat:

Skipping re-renders with memo does not stop a component from receiving fresh context values.

So if a component truly depends on a context and that context changes, React.memo is not a magic shield.

10. A concrete performance pitfall: object context values are coarse-grained

This is the classic trap:

const AppContext = createContext(null);

function AppProvider({ children }) {
  const [a, setA] = useState(1);
  const [b, setB] = useState(2);

  const value = { a, b };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

Suppose one consumer only reads a:

function ShowA() {
  const value = useContext(AppContext);
  return <div>{value.a}</div>;
}

Now imagine b changes.

Even though ShowA only cares about a, the provider value object is a new reference, so Object.is(oldValue, newValue) is false.

That means React treats the context value as changed and can schedule all consumers of that context, including ShowA.

This is why people say plain Context has no selector behavior built in:

useContext subscribes to the whole context value identity, not to one selected field inside it.

11. The practical optimizations that fall out of the internals

Once you understand the implementation, the usual optimization advice stops sounding magical.

1. Split unrelated contexts

If theme and auth change for different reasons, do not force them through one shared object context unless you really need to.

Separate contexts reduce the number of consumers affected by each update.

2. Stabilize provider values when appropriate

If you create a new object or function every render, the provider value identity changes every render too.

Patterns like this can help:

const value = useMemo(() => ({ a, b }), [a, b]);

That does not make updates free. It only ensures the context value identity changes when its real inputs change.

3. Place providers thoughtfully

A high-level provider above a huge subtree can fan out change propagation broadly.

Sometimes moving a provider lower in the tree reduces how much of the app React has to consider for that context.

12. The interview answer version

If someone asks, "How does useContext work internally?", a strong answer is:

  1. createContext creates a shared context object with mutable current-value fields
  2. during render, providers push the previous value to an internal stack and replace the current value for their subtree
  3. useContext reads that current value in O(1) through readContext(...)
  4. while reading, React records a context dependency on the current consumer Fiber
  5. when a provider value changes by Object.is, React propagates that context change through the subtree, finds matching consumer dependencies, and marks them for work
  6. because propagation marks the consumer and its ancestor path, context updates can still reach consumers even through memoized parents

That answer is much more precise than saying only "React stores context globally."

13. Final takeaway

useContext is elegant because the read path is so small:

  • a context object holds the current render-time value
  • providers push and pop values as React enters and exits subtrees
  • consumers read the current value directly
  • React records dependencies so later provider changes can find those consumers again

So the clean mental model is:

Context reads are cheap because React turns tree lookup into stack-managed current-value access. Context updates are broader because React must propagate changed values to all matching consumers in the subtree.

Once that clicks, useContext stops feeling like magic and starts looking like a very intentional tradeoff: extremely cheap reads, but coarse-grained subscriptions unless you split or stabilize your contexts carefully.

Reviewed by

DevDepth Editor

Editor and frontend engineering writer

DevDepth publishes practical guides on React, Next.js, TypeScript, frontend architecture, browser APIs, and performance optimization.

Each article should be reviewed for technical accuracy, code clarity, metadata quality, and internal-link fit before it goes live.

Last editorial review: 2026-03-17

Contact the editor