Understanding the Render Process in React

Time: Column:Mobile & Frontend views:251

We’ve finally reached the render phase, where we delve into how React displays the UI and processes state changes—the core functionality of React. But how can we definitively determine if we’re in the render phase? While opinions vary, we’ll clarify this concept here.

In the React source code, the state is represented using the global variable executionContext:

export const NoContext = /*             */ 0b000; // 0
const BatchedContext = /*               */ 0b001; // 1
const RenderContext = /*                */ 0b010; // 2
const CommitContext = /*                */ 0b100; // 4

For this article, we adopt the mainstream definition: any process where executionContext includes RenderContext is considered the render phase. In previous sections, we explored priority concepts and learned that React schedules asynchronous tasks through the Scheduler. This task, essentially a function named performConcurrentWorkOnRoot or performSyncWorkOnRoot, is responsible for executing tasks, and this article focuses on the render phase within these functions.

Before the Render Phase

In the previous article, “Understanding React: Prioritization (Part 2),” we discussed that the task scheduled for execution is performConcurrentWorkOnRoot. However, it doesn’t immediately enter the render process. Let’s see what happens beforehand:

function performConcurrentWorkOnRoot(root, didTimeout) {
    // didTimeout is provided by the Scheduler; it returns false if there are remaining time slices, true if not.
    ...
    var lanes = getNextLanes(
      root,
      root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes
    );

    if (lanes === NoLanes) {
      return null;
    }

    var shouldTimeSlice = 
      !includesBlockingLane(root, lanes) &&
      !includesExpiredLane(root, lanes) &&
      !didTimeout;
    var exitStatus = shouldTimeSlice
      ? renderRootConcurrent(root, lanes)
      : renderRootSync(root, lanes);
    
    // The rest of this handles the commit phase, which we’ll cover later.
    ...

    return null;
}

The function first obtains the current task priority, which informs subsequent state calculations. Even when performConcurrentWorkOnRoot is called, concurrent rendering is not guaranteed; it depends on certain conditions based on the task's priority.

Conditions for Concurrent Rendering

To enter concurrent rendering mode, three conditions must be met:

  1. Task Priority Excludes includesBlockingLane Priority

    function includesBlockingLane(root, lanes) {
      var SyncDefaultLanes =
        InputContinuousHydrationLane |
        InputContinuousLane |
        DefaultHydrationLane |
        DefaultLane;
      return (lanes & SyncDefaultLanes) !== NoLanes;
    }

    The task priority must not include SyncDefaultLanes.

  2. Task Priority Is Not Expired

    function includesExpiredLane(root, lanes) {
      return (lanes & root.expiredLanes) !== NoLanes;
    }

    This check returns true if the task priority isn’t in root.expiredLanes.

  3. Scheduler Has Remaining Time Slices
    The Scheduler must still have available time slices.

If it’s the initial render, it defaults to synchronous rendering since the priority is DefaultLane, which doesn’t meet the first condition.

Understanding the Render Process in React

In such cases, we proceed to renderRootSync, which signifies the start of the render phase from the root.

Render Phase

Next, the execution flow enters renderRootSync.

function renderRootSync(root, lanes) {
    var prevExecutionContext = executionContext;
    executionContext |= RenderContext; // Mark the render phase
    var prevDispatcher = pushDispatcher();  
    if (
      workInProgressRoot !== root ||
      workInProgressRootRenderLanes !== lanes
    ) {
      // Set transition priority
      workInProgressTransitions = getTransitionsForLanes();
      // Prepare the workInProgress stack and set Update on shared.pending
      prepareFreshStack(root, lanes);
    }
    // Begin synchronous render
    do {
      try {
        workLoopSync();
        break;
      } catch (thrownValue) {
        handleError(root, thrownValue);
      }
    } while (true);
    // Reset context and other global variables
    resetContextDependencies();
    executionContext = prevExecutionContext;
    popDispatcher(prevDispatcher);
    workInProgressRoot = null;
    workInProgressRootRenderLanes = NoLanes;
    return workInProgressRootExitStatus;
}

Step 1:

The line executionContext |= RenderContext officially marks the flow as in the render phase.

Step 2:

The dispatcher is prepared. This dispatcher refers to the useState and similar hooks called through the global variable ReactCurrentDispatcher$2.current. By assigning different objects to it, we can call different APIs. This mechanism prevents hooks from being used outside components, as doing so causes errors because of the dispatcher configuration.

Understanding the Render Process in React

For the initial mount, workInProgressRoot will be empty, so an empty workInProgress is prepared. The render process essentially generates the latest ReactElements based on the most recent state, then calculates differences with the current tree to mark the changes. Here’s what prepareFreshStack does:

function prepareFreshStack(root, lanes) {
    root.finishedWork = null;
    root.finishedLanes = NoLanes;

    if (workInProgress !== null) {
      ...
    }
    // Assign values to global variables related to workInProgress
    workInProgressRoot = root;
    // Create an empty workInProgress tree
    var rootWorkInProgress = createWorkInProgress(root.current, null);
    // Set global variables related to workInProgress
    workInProgress = rootWorkInProgress;
    workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes;
    workInProgressRootExitStatus = RootInProgress;
    ...
    finishQueueingConcurrentUpdates();

    return rootWorkInProgress;
}

The purpose here is to prepare workInProgress. createWorkInProgress clones the current tree into a new Fiber node:

function createWorkInProgress(current, pendingProps) {
    var workInProgress = current.alternate;
    if (workInProgress === null) {
      workInProgress = createFiber(
        current.tag,
        pendingProps,
        current.key,
        current.mode
      );
      workInProgress.elementType = current.elementType;
      workInProgress.type = current.type;
      workInProgress.stateNode = current.stateNode;
      workInProgress.alternate = current;
      current.alternate = workInProgress;
    } else {
      workInProgress.pendingProps = pendingProps;
      workInProgress.type = current.type; 
      workInProgress.flags = NoFlags;
      workInProgress.subtreeFlags = NoFlags;
      workInProgress.deletions = null;
    }
    workInProgress.flags = current.flags & StaticMask;
    workInProgress.childLanes = current.childLanes;
    workInProgress.lanes = current.lanes;
    workInProgress.child = current.child;
    workInProgress.memoizedProps = current.memoizedProps;
    workInProgress.memoizedState = current.memoizedState;
    workInProgress.updateQueue = current.updateQueue;
    ...
    return workInProgress;
}

Now, the memory contains this structure, and we proceed with constructing the workInProgress tree. Initially, this executes synchronously via workLoopSync:

Understanding the Render Process in React

function workLoopSync() {
    while (workInProgress !== null) {
      performUnitOfWork(workInProgress);
    }
}

Since the first workInProgress node is prepared, this loop will execute until the entire tree has been reconciled.

function performUnitOfWork(unitOfWork) {
    var current = unitOfWork.alternate;
    var next;
    // Start reconciliation
    next = beginWork(current, unitOfWork, subtreeRenderLanes);
    
    unitOfWork.memoizedProps = unitOfWork.pendingProps;

    if (next === null) {
      // Complete unit work
      completeUnitOfWork(unitOfWork);
    } else {
      workInProgress = next;
    }
    ReactCurrentOwner$2.current = null;
}

The Render Phase Breakdown

Every Fiber node undergoes beginWork and completeWork. Suppose we have the following DOM structure:

const App = () => {
   return (
     <div>
       <p>
         <span>hello</span>
       </p>
       <span>Understanding React in Depth</span>
     </div>
   )
}

The reconciliation process unfolds as follows:

  1. beginWork: RootFiber

  2. beginWork: App

  3. beginWork: div

  4. beginWork: p

  5. beginWork: span-hello

  6. completeWork: span-hello

  7. completeWork: p

  8. beginWork: span-Understanding React in Depth

  9. completeWork: span-Understanding React in Depth

  10. completeWork: div

  11. completeWork: App

  12. completeWork: RootFiber

The reconciliation ends here.

Understanding the Render Process in React

Two Trees

Throughout the React runtime, two Fiber trees always exist in memory. The task of the render phase is to build a workInProgress tree based on the current current tree, which will be the next to render.

Initialization

During the mount phase (when the page loads from scratch), React establishes a workInProgress tree through the initialization process.

Understanding the Render Process in React

After going through the render process, this structure exists in memory. React uses workInProgress to render the actual DOM interface, and then points the current pointer to this workInProgress.

Understanding the Render Process in React

Update

In the update phase, a new workInProgress tree is reconstructed in memory based on the current current tree. This process aims to reuse as many of the previous Fiber nodes as possible. After completing the render process, the current pointer updates to the new workInProgress tree.

Understanding the Render Process in React