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:
Task Priority Excludes
includesBlockingLane
Priorityfunction includesBlockingLane(root, lanes) { var SyncDefaultLanes = InputContinuousHydrationLane | InputContinuousLane | DefaultHydrationLane | DefaultLane; return (lanes & SyncDefaultLanes) !== NoLanes; }
The task priority must not include
SyncDefaultLanes
.Task Priority Is Not Expired
function includesExpiredLane(root, lanes) { return (lanes & root.expiredLanes) !== NoLanes; }
This check returns
true
if the task priority isn’t inroot.expiredLanes
.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.
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.
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
:
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:
beginWork
: RootFiberbeginWork
: AppbeginWork
: divbeginWork
: pbeginWork
: span-hellocompleteWork
: span-hellocompleteWork
: pbeginWork
: span-Understanding React in DepthcompleteWork
: span-Understanding React in DepthcompleteWork
: divcompleteWork
: AppcompleteWork
: RootFiber
The reconciliation ends here.
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.
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
.
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.