Skip to main content

How root.render() works

To render a UI using React, you should perform the following as the very first steps:

  • Create a root object using createRoot.
  • 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:

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:

  1. Throw if the root._internalRoot (FiberRootNode) is null, which means that the root.unmount was called (or done manually).
  2. Perform some __DEV__ checks and warnings:
    1. If you pass a second argument of type function, like the legacy ReactDOM.render(children, callback).
    2. If you pass the children as a second argument, it guesses that you are using the legacy signature.
    3. If you pass anything as a second argument.
  3. 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 the SyncLane(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, to n & -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 with highestPriorityLane in the future (or, the smallest Lane present in the given Lanes number 😉).

    React Lanes are smartly ordered.

    // let say we have this arbitrary lanes number:
    // 0b000_1011_0000
    // the highest priotiry lane would be 0b000_0001_0000

    So, 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 the TransitionLanes 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 by startTransition;

    React.startTransition(() =>  {
    root.render(children);
    })
  • If the global currentUpdatePriority is set and different from NoLane (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 give getCurrentEventPriority(), which in our DOM case, will use window.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:

  1. return null if the fiber.updateQueue is null. null is only returned from this path and means that this fiber has been unmounted.

  2. Warn in dev about nested setState calls: calling setState from with the same setState(prev => next). This warning will be only from class component, for function components, the latest setState 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>
    );
    }
  3. If the update is a render phase class component update (not function component useState or useReducer hook), then:

    1. 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 this pending queue that has at most two elements, it will start by disconnecting them and start with the second.
    2. Traverse the tree up to the root searching for the FiberRootNode of this tree. Let's dive into this process:
      return unsafe_markUpdateLaneFromFiberToRoot(fiber, lane);
      1. Traverse the tree the first time via getRootForUpdatedFiber(fiber) searching for the Fiber that has a fiber.return equal to null with a HostRoot tag. While traversing, React was in the meantime counting the nested updates and will throw if it detects any anomaly.
      2. The Second traversal via markUpdateLaneFromFiberToRoot(root, null, lane) would add the obtained updateLane to all the parents encountered until reaching the HostRoot, While looping, it would add special cases for OffscreenComponents, we will leave this for a later section.
  4. If the update isn't a class component render one, which is the default branching when coming from root.render(), then it will return 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):

    1. 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.

    2. Add the lane(updateLane) to the global concurrentlyUpdatedLanes:

      // simplified
      concurrentlyUpdatedLanes |= lane;
    3. Merge the lane into the fiber (fiberRoot.current) and its alternate (Oh! The alternate!):

      fiber.lanes = mergeLanes(fiber.lanes, lane);
      const alternate = fiber.alternate;
      if (alternate !== null) {
      alternate.lanes = mergeLanes(alternate.lanes, lane);
      }

      Finally, the HostRoot is returned via getRootForUpdatedFiber(fiber).

    note

    Either ways, we obtained now our HostRoot which is of type FiberRootNode, calling enqueueUpdate(currentFiber, update, lane) returned the HostRoot 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 the Fiber's updateQueue.

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:

  1. Warn in dev if the update is scheduled when running insertion effects

  2. When the root is in progress and SuspendedOnData, then many global variables used by React will get reset. This section when until using root.render(children) isn't relevant, we will come back to this in a later section.

  3. Mark the root as updated: This process, as you may have guessed, will add the updateLane to the root's property: the pendingLanes, which refers to the pending root's work.

    Then, if this update isn't Idle (which is the case coming from root.render(children)), then we will reset two properties from the root: the suspendedLanes and pingedLanes: 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;
    }
    }
  4. If the update is a render phase update, then you will be warned if the update is from another component.

  5. If the update is a normal one, then:

    1. ensureRootIsScheduled(root) is called which will:

      1. reference this root in at least one of the global firstScheduledRoot and lastScheduledRoot variables.

      2. schedule a microtask via scheduleMicrotask that will processRootScheduleInMicrotask which will loop over scheduledRoots and process them.

        scheduleImmediateTask(processRootScheduleInMicrotask)

        The processing isn't executed but only scheduled, let's skip it for now, it will be detailed later.

    2. Will flush the updates right away if the root is Legacy.

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's shared.lanes will be intersected with the root's pendingLanes.

    This will leave only common lanes present in both of them.

  • Then, merge with the updateLane which will contain a TransitionLane in this case, and then assigned into the fiber.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:

    1. Add the newQueueLanes to the root.entangledLanes.
    2. Reference the root.entanglements array before the loop.
    3. Reference them as a lanes variable, then, while this lanes isn't 0:
      1. Compute current lanes index: The index is equal to 31 minus the count of leading zeros of the current lanes number. Which is the position of the first significant bit (1) of the binary representation of current lanes.

      2. Since the lanes number may be a composition of multiple lanes together, the lane at the most significant bit can be computed by shifting to the left from 1 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 ;) )
      3. If The lane exists in the newQueueLanes and is transitively entangled with the newQueueLanes, then the newQueueLanes are added to the lane'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;
        }
      4. Remove the current lane from the lanes and continue the loop until we reach 0.

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.

note

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.