How root.render()
works
To render a UI using React, you should perform the following as the very first steps:
- Create a
root
object 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.unmount
was 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
children
as 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 Lane
s 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
setState
during render), then the highest priority lane is returned:lane & -lane
technically, which will be equal for a number n, ton & -n = 2^k
where k is the position of the rightmost bit in the binary representation of n. So we will refer to this gymnastic withhighestPriorityLane
in the future (or, the smallestLane
present in the givenLanes
number 😉).React
Lane
s 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 theTransitionLanes
defined 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
currentUpdatePriority
is 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
Host
environment to givegetCurrentEventPriority()
, which in ourDOM
case, will usewindow.event
to 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
null
if thefiber.updateQueue
is null.null
is only returned from this path and means that thisfiber
has been unmounted.Warn in dev about nested
setState
calls: callingsetState
from with the samesetState(prev => next)
. This warning will be only from class component, for function components, the latestsetState
class 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
useState
oruseReducer
hook), 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 thispending
queue that has at most two elements, it will start by disconnecting them and start with the second. - Traverse the tree up to the
root
searching for theFiberRootNode
of this tree. Let's dive into this process:return unsafe_markUpdateLaneFromFiberToRoot(fiber, lane);
- Traverse the tree the first time via
getRootForUpdatedFiber(fiber)
searching for theFiber
that has afiber.return
equal tonull
with aHostRoot
tag
. 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 obtainedupdateLane
to 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);
}enqueueUpdate
this time does the following (Please ignore the comment about rendering there for now, we are surely not rendering yet):Capture in a global
concurrentQueues
the 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
HostRoot
is returned viagetRootForUpdatedFiber(fiber)
.
noteEither ways, we obtained now our
HostRoot
which is of typeFiberRootNode
, callingenqueueUpdate(currentFiber, update, lane)
returned theHostRoot
of 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
mode
and so on. - Resolve the top level react
context
object. - Create an
Update
object. - Enqueue this
update
on 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 effects
When the
root
isin progress
andSuspendedOnData
, 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
root
as updated: This process, as you may have guessed, will add theupdateLane
to theroot
's property: thependingLanes
, which refers to the pending root's work.Then, if this
update
isn'tIdle
(which is the case coming fromroot.render(children)
), then we will reset two properties from theroot
: thesuspendedLanes
andpingedLanes
: 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
normal
one, then:ensureRootIsScheduled(root)
is called which will:reference this root in at least one of the global
firstScheduledRoot
andlastScheduledRoot
variables.schedule a microtask via
scheduleMicrotask
that willprocessRootScheduleInMicrotask
which will loop overscheduledRoots
and 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
root
isLegacy
.
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.lanes
will be intersected with theroot
'spendingLanes
.This will leave only common lanes present in both of them.
Then, merge with the
updateLane
which will contain aTransitionLane
in this case, and then assigned into thefiber.updateQueue.shared.lanes
The last step will be to
markRootEntangled(root, newQueueLanes)
: This is a complex process, so let's get into it step by step:- Add the
newQueueLanes
to theroot.entangledLanes
. - Reference the
root.entanglements
array before the loop. - Reference them as a
lanes
variable, then, while thislanes
isn't0
:Compute current
lanes
index: The index is equal to31
minus the count of leading zeros of the currentlanes
number. Which is the position of the first significant bit (1
) of the binary representation of currentlanes
.Since the
lanes
number may be a composition of multiple lanes together, thelane
at the most significant bit can be computed by shifting to the left from1
the 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
newQueueLanes
and is transitively entangled with thenewQueueLanes
, then thenewQueueLanes
are 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
lane
from thelanes
and 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.