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.
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:
useContextfeels 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
useContextcan 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:
- Providers update a mutable "current value" field as React enters and exits their subtrees
useContextreads that current value inO(1)- 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:
- save the old current value on a stack
- 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
_currentValueor_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:
- it reads the current value
- it creates a context dependency record
- 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)istrue, 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:
- React detects that one or more provider values changed
- it propagates those changed contexts through the relevant subtree
- it checks each visited Fiber's context dependency list
- if a dependency matches the changed context, React marks work on that consumer Fiber
- 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
useContextis 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
memodoes 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:
useContextsubscribes 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:
createContextcreates a shared context object with mutable current-value fields- during render, providers push the previous value to an internal stack and replace the current value for their subtree
useContextreads that current value inO(1)throughreadContext(...)- while reading, React records a context dependency on the current consumer Fiber
- 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 - 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