In-depth Understanding of React: Initialization Process

Time: Column:Mobile & Frontend views:230

Let’s dive straight into React’s initialization process today. Understanding this process helps us see how React maps JSX step-by-step to the interface, which is essential. As we know, in React v18, we generally render our root component App using the following approach:

// Step 1: Create root
const root = ReactDOM.createRoot(document.getElementById("root"));
// Step 2: Render
root.render(<App />);

This process consists of two main steps:

  1. Creating the root node (root).

  2. Calling the render method to render the component.

This article aims to simplify and analyze how this process ultimately renders on the page. By the end, you will have answers to questions like:

  • What’s the difference between fiberRoot and rootFiber?

  • What is event priority?

  • What does the structure of a fiber look like?

  • Where are React’s events registered?

  • How does React address the performance issues caused by too many events?

  • And more…

Note: This article simplifies the core elements from the source code. For detailed specifics, visit the React repository.


Pre-Preparation Phase

Before rendering, React 18 goes through a pre-preparation phase, mainly to create a root node, which starts with ReactDOM.createRoot. Let’s break it down:

function createRoot(container, options) {
  // ...some validations
  var isStrictMode = false;
  var concurrentUpdatesByDefaultOverride = false;
  var identifierPrefix = "";
  var onRecoverableError = defaultOnRecoverableError;
  var transitionCallbacks = null;
  // ...options parameter, generally not used
  // Create root
  var root = createContainer(
    container,
    ConcurrentRoot, // 1
    null,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onRecoverableError
  );
  listenToAllSupportedEvents(container);
  return new ReactDOMRoot(root); // Wrap the root
}

The entry function is straightforward and mainly does two things:

  1. Creates the root node.

  2. Sets up event listeners.

When we create the root using createRoot(), it’s tagged to indicate that the app is in Concurrent mode, guiding the later reconciliation process. There are actually two modes:

var LegacyRoot = 0; // ReactDOM.render(<App />, document.getElementById("#root"))
var ConcurrentRoot = 1; // ReactDOM.createRoot(document.getElementById("#root")).render(<App />)

After initialization, all interactions are event-driven, so events need to be registered early. Now, let’s look at each step.


Creating the Root

We need to look into createContainer in more detail:

// createContainer is simple; its purpose is to create a FiberRoot, which we call the root node
function createContainer(
    containerInfo,
    tag,
    hydrationCallbacks,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onRecoverableError,
    transitionCallbacks
  ) {
    var hydrate = false;
    var initialChildren = null;
    return createFiberRoot(
      containerInfo,
      tag,
      hydrate,
      initialChildren,
      hydrationCallbacks,
      isStrictMode,
      concurrentUpdatesByDefaultOverride,
      identifierPrefix,
      onRecoverableError
    );
  }
  
// The above essentially calls createFiberRoot directly, so let’s look at what this function does
function createFiberRoot(
    containerInfo, // This is the #root DOM
    tag, // Represents Concurrent mode, with a value of 1
    // The following are mostly false or null, not used right now
    hydrate,
    initialChildren,
    hydrationCallbacks,
    isStrictMode,
    concurrentUpdatesByDefaultOverride, 
    identifierPrefix,
    onRecoverableError,
    transitionCallbacks
  ) {
    // Instantiate a new FiberRootNode
    var root = new FiberRootNode(
      containerInfo,
      tag,
      hydrate,
      identifierPrefix,
      onRecoverableError
    );
    // Create a Fiber node representing the root Fiber node of type Fiber
    var uninitializedFiber = createHostRootFiber(tag, isStrictMode);
    // Link them with mutual references
    root.current = uninitializedFiber;
    uninitializedFiber.stateNode = root;
    // Create an initial state and assign it to the first Fiber node
    {
      var _initialState = {
        element: initialChildren,
        isDehydrated: hydrate,
        cache: null,
        // not enabled yet
        transitions: null,
        pendingSuspenseBoundaries: null,
      };
      uninitializedFiber.memoizedState = _initialState;
    }
    // Initialize the updateQueue for the first Fiber node
    initializeUpdateQueue(uninitializedFiber);
    return root;
  }

This function performs several key tasks:

  1. Creates the actual root node (FiberRoot).

  2. Creates the first Fiber node.

  3. Links the nodes with mutual references.

  4. Initializes the first Fiber’s memoizedState and updateQueue.

After this step, the structure in memory looks like this:

In-depth Understanding of React: Initialization Process

This overall process is relatively simple. It’s crucial to understand the two types involved: FiberRoot and RootFiber.

FiberRoot and RootFiber

FiberRoot is a type of root node, and in the runtime of a React application, there is only one instance of it. React relies on FiberRoot to switch between fiber trees for updates, making it a crucial component. Let's take a closer look at its implementation.

Code Analysis

function FiberRootNode(
  containerInfo,
  tag,
  hydrate,
  identifierPrefix,
  onRecoverableError
) {
  this.tag = tag; // LegacyRoot/ConcurrentRoot
  this.containerInfo = containerInfo; // root DOM node
  this.pendingChildren = null;
  this.current = null; // Fiber tree of the current view
  this.pingCache = null;
  this.finishedWork = null; // Committed fiber tree
  this.timeoutHandle = noTimeout; // Macro task timer
  this.context = null;
  this.pendingContext = null; // Value from getContextForSubtree(parentComponent)
  this.callbackNode = null; // Task type in the scheduler system
  this.callbackPriority = NoLane; // Priority in the scheduler system (1, 2, 3, 4, 5)
  this.eventTimes = createLaneMap(NoLanes); 
  // Array of 32 timestamps representing events' trigger times
  this.expirationTimes = createLaneMap(NoTimestamp);
  // Array of 32 timestamps for expiration times, calculated based on lane and eventTime

  // Priority scheduling
  this.pendingLanes = NoLanes;
  this.suspendedLanes = NoLanes;
  this.pingedLanes = NoLanes;
  this.expiredLanes = NoLanes;
  this.mutableReadLanes = NoLanes;
  this.finishedLanes = NoLanes; // Commit render
  this.entangledLanes = NoLanes;
  this.entanglements = createLaneMap(NoLanes);
  this.identifierPrefix = identifierPrefix;
  this.onRecoverableError = onRecoverableError;

  {
    this.mutableSourceEagerHydrationData = null;
  }

  {
    this.effectDuration = 0;
    this.passiveEffectDuration = 0;
  }

  {
    this.memoizedUpdaters = new Set();
    var pendingUpdatersLaneMap = (this.pendingUpdatersLaneMap = []);
    for (var _i = 0; _i < TotalLanes; _i++) {
      pendingUpdatersLaneMap.push(new Set());
    }
  }
  ...
}

As you can see, the only actual values assigned are the root DOM node (#root) and the tag identifier. The rest are various fields with specific functions, which will be covered in later sections.

In-depth Understanding of React: Initialization Process

Understanding React's Fiber Type

Many of you may already be familiar with React’s Fiber type, as it’s a central concept in React. RootFiber is the first Fiber node, and I will cover some basics of the Fiber type for completeness. Those who already know this well can skip ahead.

In React, Fiber is a unique data structure, not purely a tree or linked list but a hybrid of both. It’s integral to the initialization and update processes of React runtime. Fiber is foundational to features like hooks, update interruption, and priority management. Without Fiber, implementing these features would be exceedingly challenging.

There are 26 types of Fiber, categorized as follows:

export const FunctionComponent = 0; // Functional component
export const ClassComponent = 1; // Class component
export const IndeterminateComponent = 2; // Component type undecided (function or class)
export const HostRoot = 3; // Root Fiber component of the app
export const HostPortal = 4; // Entry point for a different renderer
export const HostComponent = 5; // Native DOM component
export const HostText = 6; // Native text component
export const Fragment = 7; // Empty component

// For reference only
export const Mode = 8;
export const ContextConsumer = 9;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const Profiler = 12;
export const SuspenseComponent = 13;
export const MemoComponent = 14;
export const SimpleMemoComponent = 15;
export const LazyComponent = 16;
export const IncompleteClassComponent = 17;
export const DehydratedFragment = 18;
export const SuspenseListComponent = 19;
export const ScopeComponent = 21;
export const OffscreenComponent = 22;
export const LegacyHiddenComponent = 23;
export const CacheComponent = 24;
export const TracingMarkerComponent = 25;

Every component we create in React has a corresponding fiber node. The tag property on fiber nodes distinguishes their types.

Fiber nodes don’t hold significance individually; rather, their relationships form a tree structure, known as a fiber tree. For instance, given the following JSX:

const App = () => {
  return (
    <div>
      <button></button>
      <p>
        <span>hello</span>
        <i></i>
      </p>
    <div>
  );
}

In memory, this creates a fiber tree like the following:

In-depth Understanding of React: Initialization Process

Each node in this tree has at most one child pointer to its first child, one sibling pointer to its next sibling, and a return pointer to its parent, except for the root fiber node.

Now, returning to our main topic, the createHostRootFiber(tag, isStrictMode) function creates the first fiber node:

function createHostRootFiber(tag, isStrictMode, concurrentUpdatesByDefaultOverride) {
  var mode; // Mode is set to 1, as we are using createRoot
  if (tag === ConcurrentRoot) {
    mode = ConcurrentMode;
    if (isStrictMode === true) {
      mode |= StrictLegacyMode;
      mode |= StrictEffectsMode;
    }
  } else {
    mode = NoMode;
  }
  return createFiber(HostRoot, null, null, mode);
}

// createFiber
var createFiber = function (tag, pendingProps, key, mode) {
  return new FiberNode(tag, pendingProps, key, mode); // Creates a new fiber instance
};

// FiberNode function
function FiberNode(tag, pendingProps, key, mode) {
  this.tag = tag; // Fiber type (26 types)
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;
  this.ref = null;
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;
  this.mode = mode;
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null;
  this.lanes = NoLanes;
  this.childLanes = NoLanes;
  this.alternate = null;
  ...
}

After initialization, the first fiber node lacks a return pointer, making it unique in the fiber tree.

In-depth Understanding of React: Initialization Process

Summary

We’ve examined the differences between the first fiber node, RootFiber, and the root of the application, FiberRoot. These two are interlinked and will be explored further in future articles.

Event Registration

To enhance performance, React uses event delegation. If you want an in-depth understanding of event delegation, check out this article.

In simple terms, event delegation means that, in real-world scenarios, users are likely to register various events on numerous nodes to monitor user actions. React handles all interactions by listening on the #root node. By registering only a few events on #root, it avoids having to register thousands of events on individual nodes, significantly improving performance.

This is handled by the function listenToAllSupportedEvents(container);. In React 18, all events are delegated at the #root node. Let’s look at what happens within this function:

function listenToAllSupportedEvents(rootContainerElement) {
    if (!rootContainerElement[listeningMarker]) { // Prevents duplicate registration
        rootContainerElement[listeningMarker] = true; // Adds a marker
        allNativeEvents.forEach(function (domEventName) {
            if (domEventName !== "selectionchange") { // Filters out 'selectionchange' as it doesn't bubble and can only be listened to at the Document level
                if (!nonDelegatedEvents.has(domEventName)) {
                    listenToNativeEvent(domEventName, false, rootContainerElement);
                }
                listenToNativeEvent(domEventName, true, rootContainerElement);
            }
        });

        var ownerDocument = rootContainerElement.nodeType === DOCUMENT_NODE
            ? rootContainerElement
            : rootContainerElement.ownerDocument;
        if (ownerDocument !== null && !ownerDocument[listeningMarker]) {
            ownerDocument[listeningMarker] = true;
            listenToNativeEvent("selectionchange", false, ownerDocument);
        }
    }
}

At this point, React registers nearly all browser events (a total of 82), as shown in the image below.

In-depth Understanding of React: Initialization Process

Among these events, three categories exist: selectionchange, events that don't support bubbling, and regular events. They are each handled separately, as you can see in the code. Each event type has unique characteristics. For instance, selectionchange has no effect on regular DOM elements, and events like cancel and abort don't bubble. Refer to the MDN documentation for more information on events.

To closely simulate the native event flow, React listens at both the capture and bubble phases. This is an improvement in v18, as earlier versions used an array to simulate bubbling and capturing, which couldn't fully align with the native event flow.

To understand which function is registered, we can examine listenToNativeEvent. Assuming the event is click, let’s focus on the core part:

function listenToNativeEvent(domEventName, isCapturePhaseListener, target) {
    var eventSystemFlags = 0;
    if (isCapturePhaseListener) {
        eventSystemFlags |= IS_CAPTURE_PHASE; // Constant 4
    }
    addTrappedEventListener(
        target, // #root
        domEventName, // e.g., 'click'
        eventSystemFlags, // 4 for capture, 0 for bubble
        isCapturePhaseListener // Capture phase flag
    );
}

The addTrappedEventListener function does two things:

  1. It creates a specific event listener function based on the event type.

  2. It registers this function on #root.

Here’s how listener is created:

function createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags) {
    var eventPriority = getEventPriority(domEventName);
    var listenerWrapper;
    switch (eventPriority) {
        case DiscreteEventPriority:
            listenerWrapper = dispatchDiscreteEvent;
            break;
        case ContinuousEventPriority:
            listenerWrapper = dispatchContinuousEvent;
            break;
        case DefaultEventPriority:
        default:
            listenerWrapper = dispatchEvent;
            break;
    }
    return listenerWrapper.bind(null, domEventName, eventSystemFlags, targetContainer);
}

Here, we introduce the concept of event priority. For an optimal user experience, each event is assigned a priority level to determine its scheduling order. For example, in a text input event, users expect an immediate response; otherwise, it feels laggy. Events like scroll, which trigger continuously, have a lower priority. This priority is set using the getEventPriority function.

If the event is click, dispatchDiscreteEvent is returned, and it is registered on #root as follows:

function addEventBubbleListener(target, eventType, listener) {
    target.addEventListener(eventType, listener, false);
    return listener;
}

This code simply adds the event listener, completing the registration.

Summary

During createRoot, RootFiber and FiberRoot are created, and all browser events are registered on #root. When these events are triggered, a listener is called (e.g., for click, dispatchDiscreteEvent is invoked).

However, the initialization process is not fully complete here. The instance exposed to the user is actually of type ReactDOMRoot:

function createRoot() {
    return new ReactDOMRoot(root);
}

function ReactDOMRoot(internalRoot) {
    this._internalRoot = internalRoot;
}

ReactDOMRoot.prototype.render = function (children) {
    var root = this._internalRoot;
    updateContainer(children, root, null, null);
};

This instance has a .render method, allowing developers to call root.render(<App/>), marking the start of the render phase, which we’ll discuss in the next section.