The Work Loop - Render Phase
performWorkOnRoot decides whether this render may be time-sliced, then calls renderRootSync or renderRootConcurrent. Both clone the HostRoot into a work-in-progress fiber…
- Published
- Reading time
- 11 min read
Where we enter
By the time you get here, an update has already been queued on a fiber and a lane assigned (root.render, setState). The root scheduler then calls into the work loop in one of two ways (Lanes & Scheduler):
- Sync lanes (e.g. a click →
SyncLane):scheduleTaskForRootDuringMicrotaskdoes not create a Scheduler task. It setsroot.callbackPriority = SyncLane, and the microtask flush callsperformSyncWorkOnRoot, which callsperformWorkOnRoot(root, lanes, forceSync = true).12 - Everything else: a Scheduler task is posted with
performWorkOnRootViaSchedulerTask.bind(null, root). That task first flushes any pending passive effects, recomputesgetNextLanes, callsperformWorkOnRoot, and returns itself as a continuation if the root still has work at that priority.3
That continuation return is how a time-sliced render picks up where it left off on the next macrotask. It is not a recursive call. Scheduler sees a function come back and keeps it as the same task.
performWorkOnRoot: the shouldTimeSlice decision
// ReactFiberWorkLoop.js (simplified)
export function performWorkOnRoot(root, lanes, forceSync) {
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
throw new Error('Should not already be working.');
}
const shouldTimeSlice =
(!forceSync &&
!includesBlockingLane(lanes) &&
!includesExpiredLane(root, lanes)) ||
checkIfRootIsPrerendering(root, lanes);
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes, true);
// ... loop handling RootInProgress / errors / store tearing ...
finishConcurrentRender(root, exitStatus, finishedWork, lanes, renderEndTime);
// ...
ensureRootIsScheduled(root);
}The decision that matters is includesBlockingLane. Its "blocking" set is SyncLane, InputContinuousLane, DefaultLane, the hydration variants of those, and GestureLane.5 That has a consequence people often miss:
The other two inputs:
includesExpiredLane: a lane that has waited too long gets marked expired, and from then on it renders synchronously so it cannot be starved.checkIfRootIsPrerendering: if the root is only prerendering (warming up siblings of a suspended tree that can't commit yet), React always uses the concurrent loop, even for sync lanes, so that this speculative work never blocks the main thread.4
After the render returns, performWorkOnRoot handles a few exit cases:
RootInProgress: the concurrent loop yielded. Break out. The scheduler task will return a continuation.- Store tearing check: if the render was concurrent,
isRenderConsistentWithExternalStores(finishedWork)walks the tree to check that everyuseSyncExternalStoresnapshot is still current. If one changed while React was yielded, it re-renders synchronously withrenderRootSync(root, lanes, false)so nothing can interleave this time.6 - Errors:
RootErroredgets one synchronous retry viarecoverFromConcurrentError.RootFatalErroredresets the stack.4 - Otherwise it calls
finishConcurrentRender, which either commits right away or, for suspended transitions and retries, marks the root suspended and may schedule a throttledcompleteRootWhenReadyviascheduleTimeout.7completeRootWhenReadycan also suspend the commit (waiting on stylesheets/images or an active View Transition) before callingcompleteRoot→commitRoot.8
renderRootSync vs renderRootConcurrent
Both functions have the same skeleton:
- Set
executionContext |= RenderContextand push the hooks dispatcher (Bootstrap). - If the root or the lanes changed since the last time we were here, call
prepareFreshStack. If not, continue the existing work-in-progress.910 - Enter an
outer: do { try { ... workLoopX() } catch (thrownValue) { handleThrow(...) } } while (true)loop. - Restore context. If
workInProgress === nullthe tree is done, so clearworkInProgressRootand return the exit status. Otherwise returnRootInProgress.1112
They differ in how they handle a thrown promise (suspension) and in which inner loop they use:
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrentByScheduler() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}shouldYield is Scheduler's shouldYieldToHost. It returns true once about frameYieldMs = 5ms have passed since the current Scheduler task started, or right away if requestPaint() was called.16 So "time slicing" in practice means checking a clock between fibers, never inside a component. A single component that takes 200ms will still block for 200ms.
prepareFreshStack + createWorkInProgress for the root
prepareFreshStack is how a render starts over from scratch:
// simplified
function prepareFreshStack(root, lanes) {
// cancel a pending timeout-commit or suspended commit, if any
// ...
resetWorkInProgressStack(); // unwind any half-finished WIP
workInProgressRoot = root;
const rootWorkInProgress = createWorkInProgress(root.current, null);
workInProgress = rootWorkInProgress;
workInProgressRootRenderLanes = lanes;
workInProgressRootExitStatus = RootInProgress;
// ... reset ~15 other module-level "workInProgressRoot*" variables ...
entangledRenderLanes = getEntangledLanes(root, lanes);
finishQueueingConcurrentUpdates(); // move interleaved updates onto fibers
return rootWorkInProgress;
}createWorkInProgress(current, pendingProps) is the double-buffering primitive (Fiber):
- If
current.alternate === null(true for the HostRoot on the very first render), it allocates a new fiber and linkswip.alternate = current; current.alternate = wip. - Otherwise it reuses the alternate object: it resets
flags(keeping onlyStaticMask),subtreeFlagsanddeletions, and copieschild,memoizedProps,memoizedState,updateQueue,lanes,childLanes,sibling,indexandreffromcurrent.18
The WIP root starts out with child = current.child. In other words it begins by pointing at the current children. Children are cloned lazily, only as beginWork reaches them, either through reconciliation or through cloneChildFibers on a bailout (beginWork & Reconciliation).
The (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) check is what makes interruption work. If a transition render yields and a click arrives, the next performWorkOnRoot call gets SyncLane. The lanes differ, so prepareFreshStack runs resetWorkInProgressStack() (which calls unwindInterruptedWork up the return path to pop contexts) and starts a new WIP from root.current.19 The half-built transition tree is simply discarded. The transition's updates are still in the queues (they were never removed, see Hooks on Update), so it is re-rendered from scratch later on top of the new state.
performUnitOfWork → beginWork / completeUnitOfWork
function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate; // null => this fiber is mounting
let next = beginWork(current, unitOfWork, entangledRenderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
completeUnitOfWork(unitOfWork); // no child: go back up
} else {
workInProgress = next; // descend into the child
}
}Notes:
currentis the mount/update switch for the whole render phase.current === nullmeans "this fiber has never been committed", and everybeginWork/completeWorkbranch tests it. On the first render the HostRoot does have a current (created bycreateRoot, see createRoot), but every fiber below it is brand new, socurrent === nullall the way down. That is why the first render is a "mount" everywhere except at the root. Page 07 explains why this means only the root's direct child gets aPlacementflag.memoizedProps = pendingPropshappens right afterbeginWork. Next render,current.memoizedPropsis whatbeginWorkcompares by identity against the newpendingPropsto decide on a bailout.beginWorkreturns the first child to work on next, ornull.
completeUnitOfWork is the "climb back up" half:
function completeUnitOfWork(unitOfWork) {
let completedWork = unitOfWork;
do {
if ((completedWork.flags & Incomplete) !== NoFlags) {
unwindUnitOfWork(completedWork, workInProgressRootDidSkipSuspendedSiblings);
return; // something threw below
}
const next = completeWork(completedWork.alternate, completedWork, entangledRenderLanes);
if (next !== null) { workInProgress = next; return; } // rare: spawned work
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) { workInProgress = siblingFiber; return; }
completedWork = completedWork.return; // no sibling: complete parent
workInProgress = completedWork;
} while (completedWork !== null);
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted;
}
}So the traversal is an iterative depth-first walk that uses three pointers per fiber (child, sibling, return) instead of the JS call stack. React used to recurse (the pre-Fiber "stack reconciler"). This design is what makes yielding possible: the entire "stack" is a single variable, workInProgress, and you can stop after any fiber and come back later.
Depth-first traversal on a small tree
function App() {
return (
<div>
<h1>Hi</h1>
<List />
</div>
);
}
function List() { return <ul><li>a</li><li>b</li></ul>; }flowchart TD
R[HostRoot] --> A[App]
A --> D[div]
D --> H[h1 'Hi']
H -. sibling .-> L[List]
L --> U[ul]
U --> LA[li 'a']
LA -. sibling .-> LB[li 'b']Order of calls (B = beginWork, C = completeWork):
Every parent completes after all its children, which is why the DOM can be built bottom-up off-screen (completeWork).
First render vs update, in the loop
The update case is the big performance property: a setState deep in the tree does not re-render from the root. beginWork runs on the ancestors, sees they have no work of their own (checkScheduledUpdateOrContext is false), and bailoutOnAlreadyFinishedWork just clones the child pointers down the childLanes trail. Details are in beginWork & Reconciliation.
Interruption, resumption and suspension (brief)
- Yield:
workLoopConcurrentBySchedulerexits withworkInProgress !== null→renderRootConcurrentreturnsRootInProgress→ the Scheduler task returns a continuation. On the next task,workInProgressRoot === root && lanesmatch, so there is noprepareFreshStackand the loop simply resumes atworkInProgress.10 - Interrupt: higher-priority lanes arrive → lanes mismatch →
prepareFreshStack→ the old WIP is thrown away (see above). - Suspend: a component calls
use(promise)or throws a promise. That surfaces as a throw out ofbeginWork, caught by thetry/catchin the render function and handed tohandleThrow, which records aworkInProgressSuspendedReason(e.g.SuspendedOnImmediate/SuspendedOnData).23 In a concurrent render, React can wait for the promise without unwinding (thenable.then(onResolution, onResolution); break outer) and later replay only that one fiber (replaySuspendedUnitOfWork) instead of re-rendering the subtree.14 In a sync render it unwinds immediately to the nearest Suspense boundary, which shows its fallback.
Finishing → commit
When completeUnitOfWork climbs past the HostRoot, workInProgress becomes null and the exit status becomes RootCompleted. performWorkOnRoot reads finishedWork = root.current.alternate, which is the finished WIP root. It calls finishConcurrentRender → completeRootWhenReady → completeRoot → commitRoot.478 Despite its name, finishConcurrentRender is called for sync renders too.
At this point the WIP tree is complete. It has DOM nodes for new host fibers, flags on every fiber that needs a side effect, and subtreeFlags bubbled up so the commit can skip clean subtrees. Nothing has been inserted into the document yet. That happens in Commit Phase.
With the React Compiler
The work loop is unchanged. The compiler rewrites your component bodies and never touches react-reconciler (React Compiler). What changes is how many fibers the loop actually processes. Compiled components return cached JSX elements when their inputs haven't changed. As described on page 07, beginWork then sees oldProps === newProps for the children and bails out of whole subtrees, so in step "Fibers visited" above, the subtree under an updated component often shrinks to just the parts whose inputs really changed. Fewer performUnitOfWork calls means less render time and fewer yields. See With vs Without Compiler.
Summary
The render phase is a while loop over one global pointer, workInProgress. performWorkOnRoot decides whether to time-slice, prepareFreshStack clones the HostRoot into a work-in-progress fiber, and performUnitOfWork walks the tree depth-first: beginWork going down, completeWork coming back up. Because the whole "stack" is one variable and nothing touches the screen, React can yield, resume, or discard a half-built tree. Render can run several times and never commit, so it must be pure. Next: beginWork.
Key takeaways
shouldTimeSlice:DefaultLaneis blocking, so the initial mount runs inrenderRootSync; only Transition, Retry, Idle and Offscreen lanes yield.- Exit handling:
performWorkOnRoothandles yields, re-renders synchronously if a concurrent render saw a store change (tearing), retries errors once, or callsfinishConcurrentRender. workLoopSyncvsworkLoopConcurrentByScheduler: the concurrent loop checksshouldYield()between fibers, never inside a component.prepareFreshStack: resets render state, creates the root WIP and drains interleaved updates onto fibers.- Interruption: when the root or lanes change, the old WIP is discarded; its updates are still in the queues.
performUnitOfWork:current === nullis the mount/update switch, andmemoizedProps = pendingPropssets up the next bailout check.completeUnitOfWork: completes a fiber, then moves to its sibling or parent, so parents complete after their children.- Updates skip work:
childLaneslimits a deepsetStateto the path to the updated fiber plus its subtree. - Suspension: a concurrent render can wait and replay one fiber; a sync render unwinds to the nearest Suspense boundary.
← Fiber · Index · beginWork & Reconciliation →
Footnotes
-
scheduleTaskForRootDuringMicrotasksync branch — packages/react-reconciler/src/ReactFiberRootScheduler.js#L442-L456 (opens in a new tab) ↩ -
performSyncWorkOnRoot— packages/react-reconciler/src/ReactFiberRootScheduler.js#L608-L622 (opens in a new tab) ↩ -
performWorkOnRootViaSchedulerTask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L513-L606 (opens in a new tab) ↩ -
performWorkOnRoot(shouldTimeSlice at L1168-L1181) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1137-L1325 (opens in a new tab) ↩ ↩2 ↩3 ↩4 -
includesBlockingLane— packages/react-reconciler/src/ReactFiberLane.js#L684-L694 (opens in a new tab) ↩ -
store-consistency check and sync re-render — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1222-L1243 (opens in a new tab) ↩
-
finishConcurrentRender— packages/react-reconciler/src/ReactFiberWorkLoop.js#L1420-L1576 (opens in a new tab) ↩ ↩2 -
completeRootWhenReady— packages/react-reconciler/src/ReactFiberWorkLoop.js#L1578-L1701 (opens in a new tab) ↩ ↩2 -
renderRootSyncfresh-stack check — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2635-L2654 (opens in a new tab) ↩ -
renderRootConcurrentfresh-stack check / resume — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2787-L2814 (opens in a new tab) ↩ ↩2 -
renderRootSync— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2623-L2768 (opens in a new tab) ↩ -
renderRootConcurrentloop selection and exit — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3007-L3053 (opens in a new tab) ↩ -
enableThrottledScheduling = false,enableViewTransition = true— packages/shared/ReactFeatureFlags.js#L76-L90 (opens in a new tab) ↩ -
renderRootConcurrentsuspended-reason switch (replay / wait on thenable) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2820-L3005 (opens in a new tab) ↩ ↩2 -
workLoopSync,workLoopConcurrent,workLoopConcurrentByScheduler— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2772-L2777 (opens in a new tab) and #L3056-L3079 (opens in a new tab) ↩ ↩2 -
shouldYieldToHost/frameInterval— packages/scheduler/src/forks/Scheduler.js#L456-L472 (opens in a new tab);frameYieldMs = 5— packages/scheduler/src/SchedulerFeatureFlags.js#L11 (opens in a new tab) ↩ -
prepareFreshStack(state reset at L2254-L2284) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2023-L2293 (opens in a new tab) ↩ -
createWorkInProgress— packages/react-reconciler/src/ReactFiber.js#L323-L437 (opens in a new tab) ↩ -
resetWorkInProgressStack— packages/react-reconciler/src/ReactFiberWorkLoop.js#L1979-L2002 (opens in a new tab) ↩ -
performUnitOfWork— packages/react-reconciler/src/ReactFiberWorkLoop.js#L3081-L3123 (opens in a new tab) ↩ -
completeUnitOfWork— packages/react-reconciler/src/ReactFiberWorkLoop.js#L3368-L3431 (opens in a new tab) ↩ -
bailoutOnAlreadyFinishedWork— packages/react-reconciler/src/ReactFiberBeginWork.js#L3826-L3865 (opens in a new tab) ↩ -
handleThrow— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2302-L2430 (opens in a new tab) ↩