How does Context work internally in React?

We write React code in a tree of components, we can pass down data by props but it is cumbersome if the tree is very deep. For example, if we want to define a global color which might be used in all components, eventually all components might need a new prop to get it supported.

Context is to solve this issue, it allows us to pass down data to sub-tree without props.

1. Demo of React Context

Here is a simple React app with Context, it just renders JSer.

import { createContext } from 'react';

const Context = createContext("123");

function Component1() {
  return <Component2 />;
}

function Component2() {
  return <Context.Consumer>
    {(value) => value}
  </Context.Consumer>;
}

export default function App() {
  return (
    <Context.Provider value="JSer">
      <Component1 />
    </Context.Provider>
  );
}

The demo is very simple, now let’s see how React Context works internally.

2. React.createContext()

import { REACT_PROVIDER_TYPE, REACT_CONTEXT_TYPE } from "shared/ReactSymbols";
import type { ReactContext } from "shared/ReactTypes";
export function createContext<T>(defaultValue: T): ReactContext<T> {
const context: ReactContext<T> = {
$$typeof: REACT_CONTEXT_TYPE,
// As a workaround to support multiple concurrent renderers, we categorize
// some renderers as primary and others as secondary. We only expect
// there to be two concurrent renderers at most: React Native (primary) and
// Fabric (secondary); React DOM (primary) and React ART (secondary).
// Secondary renderers store their context values on separate fields.
_currentValue: defaultValue,
_currentValue2: defaultValue,
// Used to track how many concurrent renderers this context currently
// supports within in a single renderer. Such as parallel server rendering.
_threadCount: 0,
// These are circular
Provider: (null: any),
Consumer: (null: any),
};
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context,

Provider holds _context as its parent ref.

};
context.Consumer = context;
return context;
}
import { REACT_PROVIDER_TYPE, REACT_CONTEXT_TYPE } from "shared/ReactSymbols";
import type { ReactContext } from "shared/ReactTypes";
export function createContext<T>(defaultValue: T): ReactContext<T> {
const context: ReactContext<T> = {
$$typeof: REACT_CONTEXT_TYPE,
// As a workaround to support multiple concurrent renderers, we categorize
// some renderers as primary and others as secondary. We only expect
// there to be two concurrent renderers at most: React Native (primary) and
// Fabric (secondary); React DOM (primary) and React ART (secondary).
// Secondary renderers store their context values on separate fields.
_currentValue: defaultValue,
_currentValue2: defaultValue,
// Used to track how many concurrent renderers this context currently
// supports within in a single renderer. Such as parallel server rendering.
_threadCount: 0,
// These are circular
Provider: (null: any),
Consumer: (null: any),
};
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context,

Provider holds _context as its parent ref.

};
context.Consumer = context;
return context;
}

So createContext() simply returns an object holding the default value and exposing Provider and Consumer

  1. Provider is a special element type of REACT_PROVIDER_TYPE, which we’ll cover it soon.

  2. Consumer is interesting, it is set to context.

Like our demo code above, Provider and Consumer are actually used in the rendering, so Context is a way to pair them.

3. Provider

Element type REACT_PROVIDER_TYPE is mapped to fiber tag ContextProvider here.

The sole purpose of Provider is to set the value used by Consumer in the syntax <Provider value={...}/>, below is how Provider is processed during rendering.

function beginWork() {
case ContextProvider:
return updateContextProvider(current, workInProgress, renderLanes);
}
function updateContextProvider(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
const providerType: ReactProviderType<any> = workInProgress.type;
const context: ReactContext<any> = providerType._context;

So we can easily get the internal Context from Provider type

const newProps = workInProgress.pendingProps;
const oldProps = workInProgress.memoizedProps;
const newValue = newProps.value;
pushProvider(workInProgress, context, newValue);

The value is pushed somewhere!

if (enableLazyContextPropagation) {
// In the lazy propagation implementation, we don't scan for matching
// consumers until something bails out, because until something bails out
// we're going to visit those nodes, anyway. The trade-off is that it shifts
// responsibility to the consumer to track whether something has changed.
} else {
if (oldProps !== null) {
const oldValue = oldProps.value;
if (is(oldValue, newValue)) {
// No change. Bailout early if children are the same.
if (
oldProps.children === newProps.children &&
!hasLegacyContextChanged()
) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
);

If there are no updates on context value and child components, React bails out

meaning React skips the rendering of sub-tree

}
} else {
// The context value changed. Search for matching consumers and schedule
// them to update.
propagateContextChange(workInProgress, context, renderLanes);

If context value changes, React needs to tell paired Consumers to update

}
}
}
const newChildren = newProps.children;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;

Continue rendering its children by returning

For more refer to How does React traverse Fiber tree internally

}
function beginWork() {
case ContextProvider:
return updateContextProvider(current, workInProgress, renderLanes);
}
function updateContextProvider(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
const providerType: ReactProviderType<any> = workInProgress.type;
const context: ReactContext<any> = providerType._context;

So we can easily get the internal Context from Provider type

const newProps = workInProgress.pendingProps;
const oldProps = workInProgress.memoizedProps;
const newValue = newProps.value;
pushProvider(workInProgress, context, newValue);

The value is pushed somewhere!

if (enableLazyContextPropagation) {
// In the lazy propagation implementation, we don't scan for matching
// consumers until something bails out, because until something bails out
// we're going to visit those nodes, anyway. The trade-off is that it shifts
// responsibility to the consumer to track whether something has changed.
} else {
if (oldProps !== null) {
const oldValue = oldProps.value;
if (is(oldValue, newValue)) {
// No change. Bailout early if children are the same.
if (
oldProps.children === newProps.children &&
!hasLegacyContextChanged()
) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
);

If there are no updates on context value and child components, React bails out

meaning React skips the rendering of sub-tree

}
} else {
// The context value changed. Search for matching consumers and schedule
// them to update.
propagateContextChange(workInProgress, context, renderLanes);

If context value changes, React needs to tell paired Consumers to update

}
}
}
const newChildren = newProps.children;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;

Continue rendering its children by returning

For more refer to How does React traverse Fiber tree internally

}

Let’s summarize how Provider works during rendering.

  1. it first update the new value by pushProvider().
  2. if value doesn’t change, try bailout.
  3. otherwise update Consumers by propagateContextChange() and render its children.

3.1 pushProvider()

export function pushProvider<T>(
providerFiber: Fiber,
context: ReactContext<T>,
nextValue: T
): void {
if (isPrimaryRenderer) {
push(valueCursor, context._currentValue, providerFiber);

Push the value into a fiber stack to collect Context info along the path

context._currentValue = nextValue;

Set the context with current value, so that Consumer could easily access

} else {
...
}
}
export function pushProvider<T>(
providerFiber: Fiber,
context: ReactContext<T>,
nextValue: T
): void {
if (isPrimaryRenderer) {
push(valueCursor, context._currentValue, providerFiber);

Push the value into a fiber stack to collect Context info along the path

context._currentValue = nextValue;

Set the context with current value, so that Consumer could easily access

} else {
...
}
}

So value is pushed onto Fiber stack structure.

We can think that fiber stack holds information along the path from root to current fiber. For cases like multiple Provider on the same Context, the stack structure makes sure that for a Fiber node, the closest value is used.

Notice that Fiber stack is storing the previous value while context itself is set to the newest value, this is because context has default values.

3.2 popProvider()

Where there is push, there is pop. popProvider() is called in completeWork().

This is to make sure that the context is reflecting the right value while the fiber tree is traversed by workInProgress. Think about an element as a sibling to a Provider, when React goes to this fiber, it should NOT have any information of the Provider, so there need to pop the Context info.

Keep in mind that the Fiber stack holds the context info along the path from root to current Fiber node.

export function popProvider(
context: ReactContext<any>,
providerFiber: Fiber
): void {
const currentValue = valueCursor.current;

Context values in the fiber stack are previous values

pop(valueCursor, providerFiber);
if (isPrimaryRenderer) {
if (
enableServerContext &&
currentValue === REACT_SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED
) {
context._currentValue = context._defaultValue;
} else {
context._currentValue = currentValue;

Set Context value to its previous value

}
} else {
...
}
}
export function popProvider(
context: ReactContext<any>,
providerFiber: Fiber
): void {
const currentValue = valueCursor.current;

Context values in the fiber stack are previous values

pop(valueCursor, providerFiber);
if (isPrimaryRenderer) {
if (
enableServerContext &&
currentValue === REACT_SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED
) {
context._currentValue = context._defaultValue;
} else {
context._currentValue = currentValue;

Set Context value to its previous value

}
} else {
...
}
}

So what popProvider() does is simple, since fiber stack stores the previous value, just set it to context and pop, done!

4. Consumer

In order to understand how propagateContextChange(), we need to first understand how Consumer works.

As mentioned above, Consumer actually is the context itself and ContextConsumer fiber tag is used (source).

function updateContextConsumer(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
) {
let context: ReactContext<any> = workInProgress.type;

Consumer type itself is the context!

const newProps = workInProgress.pendingProps;
const render = newProps.children;
prepareToReadContext(workInProgress, renderLanes);
const newValue = readContext(context);

Read the Context Value

let newChildren;
newChildren = render(newValue);

Consumer is supposed to be used in Render Prop pattern.

<Consumer>{(val) => ...}</Consumer>

// React DevTools reads this flag.
workInProgress.flags |= PerformedWork;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;

Continue rendering its children

}
function updateContextConsumer(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
) {
let context: ReactContext<any> = workInProgress.type;

Consumer type itself is the context!

const newProps = workInProgress.pendingProps;
const render = newProps.children;
prepareToReadContext(workInProgress, renderLanes);
const newValue = readContext(context);

Read the Context Value

let newChildren;
newChildren = render(newValue);

Consumer is supposed to be used in Render Prop pattern.

<Consumer>{(val) => ...}</Consumer>

// React DevTools reads this flag.
workInProgress.flags |= PerformedWork;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;

Continue rendering its children

}

Ok it is quite straightforward actually.

  1. it first prepareToReadContext()
  2. then it reads the value by readContext()
  3. since consumer expects render prop of children, so newChildren = render(newValue)

4.1 prepareToReadContext()

export function prepareToReadContext(
workInProgress: Fiber,
renderLanes: Lanes
): void {
currentlyRenderingFiber = workInProgress;
lastContextDependency = null;
lastFullyObservedContext = null;
const dependencies = workInProgress.dependencies;

A component could use multiple contexts,

dependencies means the contexts being used

if (dependencies !== null) {
if (enableLazyContextPropagation) {
// Reset the work-in-progress list
dependencies.firstContext = null;
} else {
const firstContext = dependencies.firstContext;
if (firstContext !== null) {
if (includesSomeLane(dependencies.lanes, renderLanes)) {
// Context list has a pending update. Mark that this fiber performed work.
markWorkInProgressReceivedUpdate();
}
// Reset the work-in-progress list
dependencies.firstContext = null;

Reset dependencies!

This doesn't mean much for <Consumer>{..}</Consumer> since it is just context itself

But it is more useful for useContext() which a component can have multiple calls

}
}
}
}
export function prepareToReadContext(
workInProgress: Fiber,
renderLanes: Lanes
): void {
currentlyRenderingFiber = workInProgress;
lastContextDependency = null;
lastFullyObservedContext = null;
const dependencies = workInProgress.dependencies;

A component could use multiple contexts,

dependencies means the contexts being used

if (dependencies !== null) {
if (enableLazyContextPropagation) {
// Reset the work-in-progress list
dependencies.firstContext = null;
} else {
const firstContext = dependencies.firstContext;
if (firstContext !== null) {
if (includesSomeLane(dependencies.lanes, renderLanes)) {
// Context list has a pending update. Mark that this fiber performed work.
markWorkInProgressReceivedUpdate();
}
// Reset the work-in-progress list
dependencies.firstContext = null;

Reset dependencies!

This doesn't mean much for <Consumer>{..}</Consumer> since it is just context itself

But it is more useful for useContext() which a component can have multiple calls

}
}
}
}

Keep in mind that prepareToReadContext() is NOT only called in updateContextConsumer(), but basically called when rendering all components. dependencies is to keep track of all Contexts used in a component, so later React can know which components to update when Context value changes.

4.2 readContext()

export function readContext<T>(context: ReactContext<T>): T {
const value = isPrimaryRenderer
? context._currentValue
: context._currentValue2;
if (lastFullyObservedContext === context) {
// Nothing to do. We already observe everything in this context.
} else {
const contextItem = {
context: ((context: any): ReactContext<mixed>),
memoizedValue: value,
next: null,
};
if (lastContextDependency === null) {
// This is the first dependency for this component. Create a new list.
lastContextDependency = contextItem;
currentlyRenderingFiber.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
};
if (enableLazyContextPropagation) {
currentlyRenderingFiber.flags |= NeedsPropagation;
}
} else {
// Append a new context item.
lastContextDependency = lastContextDependency.next = contextItem;

dependencies is a linked list

}
}
return value;
}
export function readContext<T>(context: ReactContext<T>): T {
const value = isPrimaryRenderer
? context._currentValue
: context._currentValue2;
if (lastFullyObservedContext === context) {
// Nothing to do. We already observe everything in this context.
} else {
const contextItem = {
context: ((context: any): ReactContext<mixed>),
memoizedValue: value,
next: null,
};
if (lastContextDependency === null) {
// This is the first dependency for this component. Create a new list.
lastContextDependency = contextItem;
currentlyRenderingFiber.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
};
if (enableLazyContextPropagation) {
currentlyRenderingFiber.flags |= NeedsPropagation;
}
} else {
// Append a new context item.
lastContextDependency = lastContextDependency.next = contextItem;

dependencies is a linked list

}
}
return value;
}

For a fiber it could use multiple context, that’s why the dependencies is actually a linked list.

readContext() simply reads the value from the context and update the dependencies.

4.3 useContext() is alias for readContext()

const HooksDispatcherOnMount: Dispatcher = {
useCallback: mountCallback,
useContext: readContext,
useEffect: mountEffect,
...
};
const HooksDispatcherOnUpdate: Dispatcher = {
useCallback: updateCallback,
useContext: readContext,
useEffect: updateEffect,
...
};
const HooksDispatcherOnMount: Dispatcher = {
useCallback: mountCallback,
useContext: readContext,
useEffect: mountEffect,
...
};
const HooksDispatcherOnUpdate: Dispatcher = {
useCallback: updateCallback,
useContext: readContext,
useEffect: updateEffect,
...
};

Yep, simple as that.

5. propagateContextChange()

When value for a context changes, we need to schedule updates on all the consumers, this is to make sure the update is not skipped somehow.

Scheduling updates basically means updating lanes and childLanes for all the node on the path from root to fiber node. You can get more info on this from How does React bailout work in reconciliation.

The source code has quite some lines, I will not paste it here.

The idea is quite simple, if you look at the code, you can easily see that the subtree under this Provider is traversed, for each fiber, dependencies is checked, if found that the context is used here. scheduleContextWorkOnParentPath() is called to schedule some work.

You might ask, wait it is scanned at the beginWork() phase of a Provider, what if the consumers are not in the tree yet ?

Good question, at first glance here, the order is a bit strange, since dependencies are only updated after Consumer is rendered. But actually we only need to scan once, because if Consumer nodes are added during the rendering, they will automatically use the latest value.

6. Summary

The Context is actually not that complex to understand. The core idea is the fiber stack that allows us to store the information along the path.

7. Coding Challenge

Now it’s time to strengthen your understanding, below is a simple coding challenge I put up. Enjoy!

Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!

😳 Would you like to share my post to more people ?    

Next: How does useLayoutEffect() work internally?