How root.render() works
To render a UI using React, you should perform the following as the very first steps:
- Create a
rootobject usingcreateRoot. - Call the
root.render(ui)function.
import { App } from "./app";
import { createRoot } from "react-dom/client";
const container = document.getElementById("root");
// This is the first step
const root = createRoot(container);
// Then, the second
root.render(<App />);
This section is all about the root.render function (the second step).
We will see its signature and how it works under the hood.
Definition
Declaration
The fiber root render method is declared here
in /packages/react-dom/src/client/ReactDOMRoot.js
Signature
The render method is defined as follows:
function render(children: ReactNodeList): void {
// [Not Native Code]
}
As opposed to what we mentally call this parameter the app or ui, in React
code it is referred as children, so let's stick to children for now 😉
The type of this parameter is ReactNodeList, which is:
type ReactNodeList = ReactEmpty | React$Node;
// where:
type ReactEmpty = null | void | boolean;
// and
type React$Node =
| null
| boolean
| number
| string
| React$Element<any>
| React$Portal
| Iterable<React$Node>;
// where
type React$Element<ElementType extends React$ElementType> = {
ref: any,
type: ElementType,
key: React$Key | null,
props: React$ElementProps<ElementType>,
}
Having this said, we can give several things to the render method,
such as the following
or any complex app you used before:
- index.html
- index.js
<body>
<div id="root1"></div>
<hr />
<div id="root2"></div>
<hr />
<div id="root3"></div>
</body>
import React, { createElement } from "react";
import { createRoot } from "react-dom/client";
createRoot(document.getElementById("root1")).render([
"Hello ",
<span key="world" style={{ color: "red" }}>
World!
</span>
]);
class ClassComponent extends React.Component {
render() {
const { initialCount } = this.props;
return <p>Class Count is: {initialCount}</p>;
}
}
createRoot(document.getElementById("root2")).render([
<ul key="list">
<li>First item</li>
<li>Second</li>
<li>Last, not third</li>
</ul>,
createElement(
function FunctionComponent({ initialCount }) {
return <span>Function Count is: {initialCount}</span>;
},
{ initialCount: 2, key: "count" }
),
<ClassComponent key="class" initialCount={3} />
]);
createRoot(document.getElementById("root3")).render([
null,
true,
false,
undefined
]);
In a nutshell, you would pass a React Element or a collection of them.
React will then render them recursively and display your interactive UI.
Implementation
As you may have noticed if you clicked on the implementation link above, the
render method looks like this:
// simplified
ReactDOMRoot.prototype.render = function render(children: ReactNodeList): void {
const root = this._internalRoot;
if (root === null) {
throw new Error('Cannot update an unmounted root.');
}
// __DEV__ only checks
updateContainer(children, root, null, null);
}
With human-readable works, this function does the following:
- Throw if the
root._internalRoot (FiberRootNode)is null, which means that theroot.unmountwas called (or done manually). - Perform some
__DEV__checks and warnings:- If you pass a second argument of type
function, like the legacyReactDOM.render(children, callback). - If you pass the
childrenas a second argument, it guesses that you are using the legacy signature. - If you pass anything as a second argument.
- If you pass a second argument of type
- call
updateContainer(children, root, null, null).
updateContainer
updateContainer is a function called from many places in the React codebase,
you may be wondering why it is called update and not render or even mount?
It is because React treats the tree always as if it is updating. React can know
which part of the tree is mounting for the same time and would execute the
necessary code each time. More on that later on this series.
It is important to analyze this function:
Signature
export function updateContainer(
element: ReactNodeList, // children
container: OpaqueRoot, // OpaqueRoot = FiberRoot = new FiberRootNode
parentComponent?: React$Component<any, any>,
callback?: Function,
): Lane {
// [Not Native Code]
}
This function does a lot of things, and is used now when mounting our tree
for the first time and later on updates.
The last two parameters were passed as null when coming from root.render,
which means that they aren't used. We will talk about them only when necessary
though.
Now with the steps of updateContainer, here is a simplified version that we
will follow along:
const current = container.current;
const lane = requestUpdateLane(current);
const update = createUpdate(lane);
update.payload = {element};
update.callback = callback;
const root = enqueueUpdate(current, update, lane);
scheduleUpdateOnFiber(root, current, lane);
entangleTransitions(root, current, lane);
1. Reference the current attached Fiber
The container passed to this function isn't the DOMElement you passed
to createRoot.
This one is the root._internalRoot which is a FiberRootNode.
The container.current property is of type FiberNode if you remember from
the previous article, which is the only Fiber your application created
until now.
React will now reference this Fiber, So current will mean fiber or
fiberNode.
const current = container.current;
2. Request an update Lane
The next thing React does is to request an update lane (a number) for the
current Fiber:
const lane = requestUpdateLane(current);
This is our first real Lanes encounter, so we've got to explain them
briefly. To understand them easily you should be familiar with bitwise
operators and number binary representations.
A Lane is a number power of 2 (1, 2, 4, 8, 16, 32...), they are
integers with only one significant (1) bit in their binary representation.
They are defined here in the React codebase.
You can see the used lanes such as: SyncLane, InputContinuousLane,
IdleLane, OffscreenLane and so on...
// from React's codebase
export const NoLane: Lane = /* */ 0b0000000000000000000000000000000;
export const SyncHydrationLane: Lane = /* */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /* */ 0b0000000000000000000000000000010;
export const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000100;
export const InputContinuousLane: Lane = /* */ 0b0000000000000000000000000001000;
export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane = /* */ 0b0000000000000000000000000100000;
export const IdleHydrationLane: Lane = /* */ 0b0010000000000000000000000000000;
export const IdleLane: Lane = /* */ 0b0100000000000000000000000000000;
export const OffscreenLane: Lane = /* */ 0b1000000000000000000000000000000;
Combining distinct lanes will result in a new arbitrary integer with
few significant bits, using the right bitwise masks will allow combining
several lanes into one single number (up to 32 states), which will allow
React to combine and detect capabilities and behavior.
Combining Lanes in React is called Lanes.
// from React's codebase
export const NoLanes: Lanes = /* */ 0b0000000000000000000000000000000;
// Pesonal comment: this should be Lanes ? i don't know
export const SyncUpdateLanes: Lane = /* */ 0b0000000000000000000000000101010;
const TransitionLanes: Lanes = /* */ 0b0000000011111111111111110000000;
The requestUpdateLane
will use the fiber.mode (from FiberNode) to infer the necessary update
lane among other variables, this function is called on runtime too after
the initial render, so we will go through it as is:
If the mode isn't concurrent (
(mode & ConcurrentMode) === NoMode), then theSyncLane(2) is returned.If it is a render phase update (calling
setStateduring render), then the highest priority lane is returned:lane & -lanetechnically, which will be equal for a number n, ton & -n = 2^kwhere k is the position of the rightmost bit in the binary representation of n. So we will refer to this gymnastic withhighestPriorityLanein the future (or, the smallestLanepresent in the givenLanesnumber 😉).React
Lanes are smartly ordered.// let say we have this arbitrary lanes number:
// 0b000_1011_0000
// the highest priotiry lane would be 0b000_0001_0000So, when updating a container from a render phase update, React will take the highest priority Lane.
If the update occurs within a
Transition, then it will select from theTransitionLanesdefined here and upgrade and/or reset the next transition lane to claim.To force a transition while coming from
root.render()at this place, you can wrap it bystartTransition;React.startTransition(() => {
root.render(children);
})If the global
currentUpdatePriorityis set and different fromNoLane(0), then it will be returned.If none of the previous conditions match, React assumes that the update originated from outside React, and then will ask the
Hostenvironment to givegetCurrentEventPriority(), which in ourDOMcase, will usewindow.eventto infer its priority.
3. Resolve and attach the subtree context
Next, React will infer and attach container(FiberRootNode).context
if it is null, or attach to container.pendingContext if already defined.
This context property is to be detailed later.
4. Create an update Object
If you remember from the previous chapter,
our FiberNode has an UpdateQueue that's used to collect pending updates,
our first real Update object is created here
for the given Lane:
// closure vars:
// - element: chilren or ui, the react node passed to root.render
// - callback: last parameter to updateContainer, null from root.render()
// simplified
const update = {
lane,
tag: UpdateState, // 0
callback, // callback or null
payload: {element}, // element are the root children
next: null,
};
5. Enqueue the created update to the Fiber
At this point, we are aware of the updateLane and have created an Update
to be applied on our FiberRoot containing our UI as a payload,
but not right away! React needs to properly schedule the processing of this
update.
To do so, the first step is to add this update to the queue:
const root: FiberRoot | null = enqueueUpdate(current, update, lane);
// current: FiberNode
// update: Update
// lane: number (update lane)
enqueueUpdate will pass through the following steps:
return
nullif thefiber.updateQueueis null.nullis only returned from this path and means that thisfiberhas been unmounted.Warn in dev about nested
setStatecalls: callingsetStatefrom with the samesetState(prev => next). This warning will be only from class component, for function components, the latestsetStateclass wins. Here is a codesandbox showing both cases.calling setState from within setState warning
This snippet showcases how to force that warning to be shown in dev, and how there is no warning from hooks setState.// this code is for demo purpose only
let instance;
class ClassComponent extends React.Component {
state = { value: "" };
render() {
instance = this;
return this.state.value;
}
}
let setState;
function FunctionComponent() {
let [state, _setState] = React.useState("");
setState = _setState;
return state;
}
function App() {
React.useEffect(() => {
instance.setState(() => {
console.log("setting class component state");
// this call will warn in dev and considers this setState every time
instance.setState({ value: "Hello !" });
return { value: "This value is ignored" };
});
setState(() => {
console.log("setting function component state");
// the state returned here is unstable since it will output a different value
// depending on whether you have StrictMode enabled, which should help you
// see that this is not the intended behavior.
// Change unstable_strictMode: false in index.js to see a behavior change.
// A warning here would be also necessary.
setState("Another value");
return "World !";
});
}, []);
return (
<div>
<ClassComponent />
<FunctionComponent />
</div>
);
}If the update is a render phase class component update (not function component
useStateoruseReducerhook), then:- Add this update to the circular queue
fiber.updateQueue.shared.pending: If there are already pending updates, the new update is put first, and then the existing pending update, which references the new update. See it here. When processing thispendingqueue that has at most two elements, it will start by disconnecting them and start with the second. - Traverse the tree up to the
rootsearching for theFiberRootNodeof this tree. Let's dive into this process:return unsafe_markUpdateLaneFromFiberToRoot(fiber, lane);- Traverse the tree the first time via
getRootForUpdatedFiber(fiber)searching for theFiberthat has afiber.returnequal tonullwith aHostRoottag. While traversing, React was in the meantime counting the nested updates and will throw if it detects any anomaly. - The Second traversal via
markUpdateLaneFromFiberToRoot(root, null, lane)would add the obtainedupdateLaneto all the parents encountered until reaching theHostRoot, While looping, it would add special cases forOffscreenComponents, we will leave this for a later section.
- Traverse the tree the first time via
- Add this update to the circular queue
If the update isn't a class component render one, which is the default branching when coming from
root.render(), then it willreturn enqueueConcurrentClassUpdate(fiber, sharedQueue, update, lane)// definition, simplified
export function enqueueConcurrentClassUpdate<State>(
// ... params
): FiberRoot | null {
// the update with our {element} as payload gets queued here
enqueueUpdate(fiber, sharedQueue, update, lane);
// we've already seen getRootForUpdatedFiber to traverse the tree
// looking the HostRoot Fiber when passing through the unsafe render
// phase class component update above
return getRootForUpdatedFiber(fiber);
}enqueueUpdatethis time does the following (Please ignore the comment about rendering there for now, we are surely not rendering yet):Capture in a global
concurrentQueuesthe 4 arguments:// simplified
concurrentQueues[id++] = fiber;
concurrentQueues[id++] = sharedQueue;
concurrentQueues[id++] = update;
concurrentQueues[id++] = lane;This global variable is reset, among many others in specific places in React. You will get to see all of them later, each one when its time comes.
Add the
lane(updateLane) to the globalconcurrentlyUpdatedLanes:// simplified
concurrentlyUpdatedLanes |= lane;Merge the lane into the
fiber(fiberRoot.current) and itsalternate(Oh! Thealternate!):fiber.lanes = mergeLanes(fiber.lanes, lane);
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}Finally, the
HostRootis returned viagetRootForUpdatedFiber(fiber).
noteEither ways, we obtained now our
HostRootwhich is of typeFiberRootNode, callingenqueueUpdate(currentFiber, update, lane)returned theHostRootof our tree.
We will do a small recap so that you are not lost here:
We started by root.render(children), In root.render we've:
- used the
fiberRoot.current(the very first created Fiber until now) - requested an update lane which depends on many factors, such as
Transition, root
modeand so on. - Resolve the top level react
contextobject. - Create an
Updateobject. - Enqueue this
updateon theFiber'supdateQueue.
The last part returned the HostRoot (of prototype FiberNode) and we
are here now. Let's continue.
6. Schedule the current Fiber update
Up until now, our current fiber has a referenced updateQueue holding our
children as a payload and other variables in the concurrentQueues array.
Now, we need to schedule an update on the current fiber via:
scheduleUpdateOnFiber(fiberRoot, currentFiber, updateLane);
The scheduleUpdateOnFiber function is called from many places in React,
and is the way to React is told to render something.
This function is called from state setters and hooks and many other places
from the React codebase.
Since we will be revisiting it later many times, we will now provide an introduction at how this function works, we may skip over code paths that aren't relevant for now.
Let's break into it:
Warn in dev if the update is scheduled when running
insertion effectsWhen the
rootisin progressandSuspendedOnData, then many global variables used by React will get reset. This section when until usingroot.render(children)isn't relevant, we will come back to this in a later section.Mark the
rootas updated: This process, as you may have guessed, will add theupdateLaneto theroot's property: thependingLanes, which refers to the pending root's work.Then, if this
updateisn'tIdle(which is the case coming fromroot.render(children)), then we will reset two properties from theroot: thesuspendedLanesandpingedLanes: The reasoning behind this is that this update could unblock or un-suspend a tree, so these properties are cleared to the tree will attempt to render again.export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
root.pendingLanes |= updateLane;
if (updateLane !== IdleLane) {
root.suspendedLanes = NoLanes;
root.pingedLanes = NoLanes;
}
}If the update is a
render phase update, then you will be warned if the update is from another component.If the update is a
normalone, then:ensureRootIsScheduled(root)is called which will:reference this root in at least one of the global
firstScheduledRootandlastScheduledRootvariables.schedule a microtask via
scheduleMicrotaskthat willprocessRootScheduleInMicrotaskwhich will loop overscheduledRootsand process them.scheduleImmediateTask(processRootScheduleInMicrotask)The processing isn't executed but only scheduled, let's skip it for now, it will be detailed later.
Will flush the updates right away if the
rootisLegacy.
7. Entangle the Fiber's transitions
This is the last step in this section, congrats if you've made it till here, this is a proof that you are curious and ambitious enough, this section was long and complex, but this is the last part of it.
If your initial render is basic, ie: simple root.render() like this
createRoot(container).render(children);
Then, nothing will be done in this function.
But if you wrap render function inside startTransition then
function entangleTransitions(root: FiberRoot, fiber: Fiber, lane: Lane) {
const sharedQueue = fiber.updateQueue.shared;
if (isTransition(lane)) {
let queueLanes = sharedQueue.lanes;
queueLanes &= root.pendignLanes;
const newLanes = queueLanes | lane;
sharedQueue.lanes = newQueueLanes;
markRootEntangled(root, newLanes);
}
}
The
fiber'sshared.laneswill be intersected with theroot'spendingLanes.This will leave only common lanes present in both of them.
Then, merge with the
updateLanewhich will contain aTransitionLanein this case, and then assigned into thefiber.updateQueue.shared.lanesThe last step will be to
markRootEntangled(root, newQueueLanes): This is a complex process, so let's get into it step by step:- Add the
newQueueLanesto theroot.entangledLanes. - Reference the
root.entanglementsarray before the loop. - Reference them as a
lanesvariable, then, while thislanesisn't0:Compute current
lanesindex: The index is equal to31minus the count of leading zeros of the currentlanesnumber. Which is the position of the first significant bit (1) of the binary representation of currentlanes.Since the
lanesnumber may be a composition of multiple lanes together, thelaneat the most significant bit can be computed by shifting to the left from1the obtained index:const lane = 1 << index;
// explanation:
// Let's assume we have a lanes of 21, which is:
// lanes = 0b0000_0000_0000_0000_0000_0000_0001_0101 = 21
// clz32(lanes) = 27
// 31 - clz32(lanes) = 31 - 27 = 4
// 1 << 4 = 0b0000_0000_0000_0000_0000_0000_0001_0000
// which is the lane with the highest number (lowest in priority ;) )If The lane exists in the
newQueueLanesand is transitively entangled with thenewQueueLanes, then thenewQueueLanesare added to thelane's entanglements (root.entanglements):// very simplified
// non-zero will be equal to the lane itself
const laneOrZero = lane & newQueueLanes;
// the existing entanglements at index
const entagledLanesAtIndex = entanglements[index];
// lanes that were entangled intersecting with new queue lanes
// those are lanes that were present already and are coming again
const persistingLanesAtIndex = entagledLanesAtIndex & newQueueLanes;
// this means that either this lane is directly present in the new lanes
// or that it is transitively present from the previous entanglements
if (laneOrZero | persistingLanesAtIndex) {
// add the new lanes to the existing entanglements
entanglements[index] |= newQueueLanes;
}Remove the current
lanefrom thelanesand continue the loop until we reach0.
- Add the
With this updateContainer(children, root, null, null) comes to an end, finally.
Recap
root.render(children) main purpose now that we've gone through is the call
the updateContainer function, which will create an update object and then
queue it to the root._internalRoot.current.shared.pending while referencing
our children as element into its payload.
While doing this, React performs many checks on the original of the update source,
most of them won't be truthy from root.render(), but it is important to know
them.
The most important part until now is the
scheduleImmediateTask(processRootScheduleInMicrotask) that we left above: it
did schedule some code to be executed later, but we haven't gone through it yet.
That's for a good reason: The work loop will start.
We've seen createRoot(container, options) and root.render(children) under
the hood and the huge amount of work done, but React still did not render any
of your components yet, all it did is to schedule the work via queueMicrotask.
This means, ehem, that React will render your components once the scripts
in your index.js file finishes 😉.
root.render(children);
triggerImportantDataFetch();
RegisterServiceWorker();
You can take this opportunity to trigger the important data loading parts so
that you can enter the pending state or suspend initially, without passing
by an effect or anything. For example, start resolving the current user.