The work loop
Follow ReactFiberWorkLoop at v19.3.1: performUnitOfWork, beginWork and completeWork on a real tree, workLoopSync vs the loop that yields, and bailouts.
- 01Trace a renderPredict the order in which React calls beginWork and completeWork on a tree, and when it creates each DOM element.
- 02Tell the two loops apartSay which updates use workLoopSync and which use the loop that checks shouldYield, and what happens when it yields.
- 03Explain skipped workRead beginWork's bailout and explain how childLanes lets React skip whole subtrees, and where the commit takes over.
A render, one fiber at a time
The fiber lesson showed the tree React keeps: one fiber per component and element, linked by child, sibling and return. This lesson shows how React walks that tree during a render. Run the cart below and read the log under it.
import { useEffect } from "react";import { note, showLog } from "./spy.js"; function CartHeader() { note("render CartHeader"); return <h2>Your cart</h2>;} function CartItem({ name, price }) { note(`render CartItem ${name}`); return <li>{name + " $" + price}</li>;} function CartFooter() { note("render CartFooter"); return <p>Free delivery over $20</p>;} export default function App() { note("render App"); useEffect(() => showLog(), []); return ( <section> <CartHeader /> <ul> <CartItem key="tea" name="Tea" price={3} /> <CartItem key="cake" name="Cake" price={4} /> </ul> <CartFooter /> </section> );}Each component calls note when React calls it (lines 5, 10, 15 and 20), so the log gets a render line. The spy.js tab makes the other lines: lines 20 to 24 there wrap document.createElement, and lines 26 to 34 wrap appendChild, the two DOM functions react-dom calls to build elements. Line 21 of App.jsx prints the log after the first commit. Read it from the top:
render App, thenrender CartHeader, thencreate <h2>. React doesn't call every component first. As soon as theh2has nothing below it, React creates its DOM element.- Only then
render CartItem Teaand its<li>, then Cake and its<li>. create <ul>comes after both items, followed by twoul.appendChild(<li>): a parent's element is made after its children's, and they go straight into it.create <section>is the last element, and it gets theh2, theuland thep.- The last line,
#root.appendChild(<section>), is the only one that touches the page. Everything before it happened on elements that weren't on the page yet.
That order isn't an accident. It's a loop in ReactFiberWorkLoop.js that goes down the tree, fiber by fiber, and back up. On the way down, it calls beginWork on each fiber; for a component, that's when React calls your function. On the way back up, it calls completeWork; for a DOM element, that's when React creates it. The picture numbers both.
beginWork and completeWork calls below StrictMode. A fiber begins on the way down and completes on the way back up, after all its children. So the h2 completes first, and App last. Each create line in the log is a host fiber's complete step.A building inspector walks into a room, writes down what's in it, and goes into the first room inside. When a room has no rooms inside, they sign it off and try the next door in the same corridor. When the corridor ends, they step back out and sign off the room around it. They only ever need one note: which room is next. With that note, they can stop for lunch and carry on exactly where they were.
- In real life: Walking into a room and noting what's in it
- In React: beginWork: render the fiber and make its child fibers
- In real life: Going into the first room inside it
- In React: Moving workInProgress to the child
- In real life: Signing off a room once every room inside it is signed off
- In React: completeWork, after all the children are complete
- In real life: Going to the next door along the same corridor
- In React: Moving to the sibling
- In real life: A note in the pocket saying which room is next
- In React: The one variable workInProgress, so the walk can stop and go on
The work loop is a while loop with one variable, workInProgress: the next fiber to work on. Each turn, performUnitOfWork calls beginWork on that fiber and moves down to the child it returns. When there is no child, it completes fibers with completeWork, moving to a sibling, or up to the parent. When workInProgress is null, the new tree is ready for the commit.
Where a render starts: performWorkOnRoot
Every render starts in performWorkOnRoot, the function the source tour's stack trace showed under your component. Its first decision is which loop to use, in ReactFiberWorkLoop.js, lines 1165 to 1181:
// We disable time-slicing in some cases: if the work has been CPU-bound
// for too long ("expired" work, to prevent starvation), or we're in
// sync-updates-by-default mode.
const shouldTimeSlice =
(!forceSync &&
!includesBlockingLane(lanes) &&
!includesExpiredLane(root, lanes)) ||
// ... (five lines of comments about prerendering left out)
checkIfRootIsPrerendering(root, lanes);
let exitStatus: RootExitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes, true);- Lines 4 to 7: React time slices, works in short pieces, only when the render isn't forced to be synchronous, has no blocking lane, and hasn't waited too long. A lane, from the fiber lesson, is the bit that says how urgent an update is; a blocking lane is one React renders without stopping. The blocking lanes are listed in includesBlockingLane:
SyncLanefor clicks and typing,InputContinuousLanefor events like scrolling, andDefaultLanefor everything else outside a transition (plus their hydration twins and an experimental gesture lane). The lanes lesson covers them all. - Line 9 adds one case, rendering ahead of time while something is suspended; the concurrent rendering lesson covers it.
- Lines 11 to 13 call
renderRootConcurrentwhen time slicing is on, andrenderRootSyncwhen it's off.
So in React 19, clicks, typing, timers and the first root.render all render with renderRootSync. Transitions, and a few kinds of background work, get renderRootConcurrent. Both start the same way, with prepareFreshStack:
workInProgressRoot = root;
const rootWorkInProgress = createWorkInProgress(root.current, null);
workInProgress = rootWorkInProgress;Line 1 remembers which root is rendering. Line 2 calls createWorkInProgress on the root's current HostRoot fiber, which hands back its twin from the fiber lesson. Line 3 stores it in workInProgress, a variable declared once for the whole file at line 441. That variable is the loop's only state. Then renderRootSync calls the loop itself, in lines 2772 to 2777:
function workLoopSync() {
// Perform work without checking if we need to yield between fiber.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}Lines 3 to 5 are the whole loop: while there is a fiber to work on, work on it. There is no recursion and no list of fibers to visit, only workInProgress, which performUnitOfWork moves along.
performUnitOfWork: one fiber, one step
One turn of the loop is one unit of work: one fiber. Here is performUnitOfWork, with the profiling and development-only copies of its main call left out:
function performUnitOfWork(unitOfWork: Fiber): void {
// The current, flushed, state of this fiber is the alternate. Ideally
// nothing should rely on this, but relying on it here means that we don't
// need an additional field on the work in progress.
const current = unitOfWork.alternate;
let next;
// ... (profiling and development-only branches left out; each one makes this same call)
next = beginWork(current, unitOfWork, entangledRenderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}- Line 5 takes the fiber's
alternate: the copy on screen, ornullon the first render. beginWork compares the two. - Line 9 calls
beginWork, which returns the next fiber to work on: this fiber's first child, ornull. - Line 10 records that this render used
pendingProps: they becomememoizedProps. - Lines 11 to 13: no child, so this fiber's part of the tree is done.
completeUnitOfWorkcompletes it and picks the next fiber. - Lines 14 and 15: there is a child, so
workInProgressmoves down to it, and the loop's next turn begins it.
beginWork either hands back a child, and the loop goes down, or null, and completeUnitOfWork takes over: it completes the fiber, then goes across to a sibling, or up to complete the parent, and so on up, until it finds a sibling or passes the root.Step through the whole walk on the overview's cart. Each press of Step runs one beginWork or completeWork, and the log on the right fills in the same lines the real log showed. Try First render first; the other two modes come up in later sections.
step 0 of 27HostRootworkInProgressStrictModeAppsectionCartHeaderh2ulCartItem TealiCartItem CakeliCartFooterp
0 linesThe renders and created elements appear here, as in the overview's log.
prepareFreshStack has pointed workInProgress at the root. Press Step to run one beginWork or completeWork at a time.
Watch workInProgress in the tree: it only moves down to a child, across to a sibling, or up to a return. It reaches every fiber twice, once to begin it and once to complete it, and after step 26 it falls off the top of the tree.
beginWork goes down, completeWork comes back up
What each call does depends on the fiber's work tag. Here is how beginWork chooses, in ReactFiberBeginWork.js:
workInProgress.lanes = NoLanes;
switch (workInProgress.tag) {
// ... (LazyComponent left out)
case FunctionComponent: {
const Component = workInProgress.type;
return updateFunctionComponent(
current,
workInProgress,
Component,
workInProgress.pendingProps,
renderLanes,
);
}
// ... (ClassComponent left out)
case HostRoot:
return updateHostRoot(current, workInProgress, renderLanes);
// ... (resources and singletons left out)
case HostComponent:
return updateHostComponent(current, workInProgress, renderLanes);
case HostText:
return updateHostText(current, workInProgress);- Line 1 clears the fiber's
lanes: whatever update it had is being handled now. - Line 3 switches on the work tag from the fiber lesson.
- Lines 5 to 14: a function component goes to updateFunctionComponent, which calls your component through renderWithHooks, then turns the elements it returns into child fibers with reconcileChildren. How React matches new elements with old fibers is the reconciliation lesson.
- Lines 16 and 17 handle the HostRoot, and lines 19 to 22 DOM elements and text. A DOM element's beginWork makes fibers for the children in its props, and changes nothing on the page.
Every branch returns the fiber's first child, or null. When it's null, completing takes over, in completeUnitOfWork:
function completeUnitOfWork(unitOfWork: Fiber): void {
// Attempt to complete the current unit of work, then move to the next
// sibling. If there are no more siblings, return to the parent fiber.
let completedWork: Fiber = unitOfWork;
do {
// ... (unwinding after an error or a suspended child left out)
const current = completedWork.alternate;
const returnFiber = completedWork.return;
let next;
// ... (profiling and development-only branches left out; each one makes this same call)
next = completeWork(current, completedWork, entangledRenderLanes);
// ... (profiling left out)
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next;
return;
}
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// If there is more work to do in this returnFiber, do that next.
workInProgress = siblingFiber;
return;
}
// Otherwise, return to the parent
// $FlowFixMe[incompatible-type] we bail out when we get a null
completedWork = returnFiber;
// Update the next thing we're working on in case something throws.
workInProgress = completedWork;
} while (completedWork !== null);
// We've reached the root.
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted;
}
}- Lines 4 and 5 start a loop at the fiber that has no child.
- Line 12 calls
completeWorkon it. Lines 14 to 18 handle the rare case where completing makes new work, such as a Suspense boundary that has to show its fallback. - Lines 20 to 25: if the fiber has a sibling,
workInProgressmoves there and the function returns, so the work loop begins the sibling next. - Lines 26 to 31: if not, it climbs to
returnFiber, the parent, and loops to complete it too, because all of the parent's children are now done. - Lines 33 to 36: climbing past the root leaves
workInProgressasnull, and the render is marked complete.
For a new DOM element, completeWork is where it's made:
case HostComponent: {
popHostContext(workInProgress);
const type = workInProgress.type;
// ... (updates, errors and hydration left out; this is a new element)
const rootContainerInstance = getRootHostContainer();
const instance = createInstance(
type,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress,
);
// TODO: For persistent renderers, we should pass children as part
// of the initial instance creation
markCloned(workInProgress);
appendAllChildren(instance, workInProgress, false, false);
workInProgress.stateNode = instance;
// ... (finalizing the element left out)
bubbleProperties(workInProgress);- Lines 6 to 12 call
createInstance, the DOM host config's function (ReactFiberConfigDOM.js, line 534), which callsdocument.createElement. That's thecreateline in the log. - Line 16,
appendAllChildren, appends the children's DOM nodes, which already exist because the children completed first. Each one is a call to appendInitialChild, theul.appendChild(<li>)lines in the log. The new element isn't on the page yet. - Line 17 stores the element in the fiber's
stateNode. - Line 19, bubbleProperties, collects the children's notes into this fiber:
let child = completedWork.child;
while (child !== null) {
newChildLanes = mergeLanes(
newChildLanes,
mergeLanes(child.lanes, child.childLanes),
);
subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;
// Update the return pointer so the tree is consistent. This is a code
// smell because it assumes the commit phase is never concurrent with
// the render phase. Will address during refactor to alternate model.
child.return = completedWork;
child = child.sibling;
}
// ... (the version that also adds up render times, and the bailout branch, left out)
completedWork.childLanes = newChildLanes;Lines 1 to 17 loop over the children through child and sibling. Lines 3 to 6 merge each child's lanes and childLanes, and lines 8 and 9 merge its flags and subtreeFlags, with the bit operators from the fiber lesson. Line 19 stores the lanes as the fiber's childLanes. This is how the fiber lesson's subtreeFlags fill in: one level per complete step, from the leaves up. (In development builds, React runs a twin of this loop that also adds up render times for the Profiler.)
How to read the table: each row asks one question of beginWork and of completeWork.
| The question | beginWork | completeWork |
|---|---|---|
| When does it run for a fiber? | On the way down, before any of its children. | On the way back up, after all of its children are complete. |
| What does it do for a component? | Calls the component and turns what it returns into child fibers. | Collects the children's flags and lanes into the fiber. |
| What does it do for a new DOM element? | Makes fibers for the children in its props. | Creates the DOM element and appends its children's elements to it. |
| What does it return? | The first child fiber, or null when there is none or it bails out. | Almost always null. Then the loop goes to the sibling, or up. |
| Which file holds it? | ReactFiberBeginWork.js | ReactFiberCompleteWork.js |
workLoopSync and the loop that yields
renderRootConcurrent runs a second loop, workLoopConcurrentByScheduler. Compare it with workLoopSync:
/** @noinline */
function workLoopConcurrentByScheduler() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// $FlowFixMe[incompatible-type] flow doesn't know that shouldYield() is side-effect free
performUnitOfWork(workInProgress);
}
}Line 4 is the only difference: && !shouldYield(). Before every unit of work, the loop asks the scheduler whether it should give the main thread back. shouldYield is the scheduler's shouldYieldToHost:
function shouldYieldToHost(): boolean {
if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) {
// Yield now.
return true;
}
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
}
// Yield now.
return true;
}- Lines 2 to 5: if React has asked for a paint, which it does after a commit, yield at once.
- Line 6 measures how long this slice has been running.
- Lines 7 to 11: under
frameInterval, keep going. Lines 12 and 13: past it, yield.
frameInterval starts at frameYieldMs, from SchedulerFeatureFlags.js, line 11:
export const frameYieldMs = 5;Five milliseconds. When the loop stops, workInProgress still points at the next fiber, and renderRootConcurrent returns "still in progress". The scheduler runs the rest in a later task, and the loop picks up from that same fiber. That's why the fiber tree is made of links: one pointer is enough to stop and go on. The scheduler lesson shows how those tasks are queued.
workLoopSync keeps the main thread until the tree is done, so a click in the meantime waits. workLoopConcurrentByScheduler stops about every 5 ms, and in each gap the browser can paint and run your event handlers. Both end with one commit.Try it. Each row below does 1 ms of busy work, and there are 100 of them. Both buttons start a timer that counts how often the browser gets a turn while React renders. Press Render now, then Render in a transition.
import { memo, startTransition, useLayoutEffect, useState } from "react"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function Row({ round, n }) { busy(1); // a slow row: 1 ms of work return <li>Tea blend {n}, round {round}</li>;} const Rows = memo(function Rows({ round }) { return <ul>{Array.from({ length: 100 }, (_, n) => <Row key={n} round={round} n={n} />)}</ul>;}); let ticks = 0;let ticking = false;function startTicker() { ticks = 0; ticking = true; const tick = () => { if (!ticking) return; ticks += 1; setTimeout(tick, 0); }; setTimeout(tick, 0);} export default function App() { const [round, setRound] = useState(0); const [report, setReport] = useState("Press a button."); useLayoutEffect(() => { if (!ticking) return; ticking = false; setReport(`While React rendered the rows, the timer ran ${ticks} times.`); }, [round]); function renderNow() { startTicker(); setRound(round + 1); } function renderInTransition() { startTicker(); startTransition(() => setRound(round + 1)); } return ( <> <button onClick={renderNow}>Render now</button> <button onClick={renderInTransition}>Render in a transition</button> <p>{report}</p> <Rows round={round} /> </> );}Lines 19 to 28 start a timer that adds 1 to ticks every time the browser runs it, and line 37 reports the count after the commit. Render now (line 42) is a click update: workLoopSync renders all 100 rows without stopping, and the timer ran 0 times. Render in a transition (line 47) uses workLoopConcurrentByScheduler: the timer runs many times, because the browser gets a turn about every 5 ms. The exact number depends on your computer. The render takes about as long either way; what changes is that the page stays responsive.
One more branch decides the loop, in renderRootConcurrent:
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
// `act` special case: If we're inside an `act` scope, don't consult
// `shouldYield`. Always keep working until the render is complete.
// This is not just an optimization: in a unit test environment, we
// can't trust the result of `shouldYield`, because the host I/O is
// likely mocked.
workLoopSync();
} else if (enableThrottledScheduling) {
workLoopConcurrent(includesNonIdleWork(lanes));
} else {
workLoopConcurrentByScheduler();
}Lines 1 to 7: inside act, the helper tests wrap updates in, even a transition renders with workLoopSync; the comment says why. Lines 8 and 9 are a variant that yields on a fixed timer instead of asking the scheduler. It sits behind enableThrottledScheduling, which is false in 19.3.1, so react-dom uses line 11. How to read the table: each row asks one question of both loops.
| The question | workLoopSync | workLoopConcurrentByScheduler |
|---|---|---|
| Who calls it? | renderRootSync | renderRootConcurrent |
| Which updates use it? | Clicks, typing, updates from timers, the first root.render, and flushSync. | Transitions, and some background work such as Suspense retries. |
| Does it stop before the tree is done? | No. It runs until workInProgress is null. | Yes, when shouldYield() returns true, after about 5 ms. |
| What can the browser do meanwhile? | Nothing. A click waits for the end. | Paint and handle events in the gaps between slices. |
Skipping work: bailouts and childLanes
After the first render, most of the tree usually hasn't changed. Click Tea $3 x 1 in the cart below, and read the log.
import { useState } from "react";import { clearLog, note, showLog } from "./spy.js"; function CartHeader() { note("render CartHeader"); return <h2>Your cart</h2>;} function CartItem({ name, price }) { note(`render CartItem ${name}`); const [quantity, setQuantity] = useState(1); function addOne() { clearLog(); setQuantity(quantity + 1); setTimeout(showLog); } return <li onClick={addOne}>{`${name} $${price} x ${quantity}`}</li>;} function CartFooter() { note("render CartFooter"); return <p>Free delivery over $20</p>;} export default function App() { note("render App"); return ( <section> <CartHeader /> <ul> <CartItem key="tea" name="Tea" price={3} /> <CartItem key="cake" name="Cake" price={4} /> </ul> <CartFooter /> </section> );}Line 15 updates Tea's own state, and the log shows a single line: render CartItem Tea. App, CartHeader, Cake and CartFooter didn't render. But the work loop starts at the root every time, so how did it get to Tea without rendering what's above it? The start of beginWork answers it:
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (
oldProps !== newProps ||
hasLegacyContextChanged() ||
// ... (a development-only check for hot reloading left out)
) {
// If props or context changed, mark the fiber as having performed work.
// This may be unset if the props are determined to be equal later (memo).
didReceiveUpdate = true;
} else {
// Neither props nor legacy context changes. Check if there's a pending
// update or context change.
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
current,
renderLanes,
);
if (
!hasScheduledUpdateOrContext &&
// If this is the second pass of an error or suspense boundary, there
// may not be work scheduled on `current`, so we check for this flag.
(workInProgress.flags & DidCapture) === NoFlags
) {
// No pending updates or context. Bail out now.
didReceiveUpdate = false;
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes,
);
}- Lines 1 to 3: on an update there is a current fiber, so React compares its old props with the new ones.
- Lines 5 to 12: different props objects, so this fiber has work to do.
- Lines 16 to 19: the same props object, so React asks checkScheduledUpdateOrContext whether the fiber's own
laneshold an update for this render, or a context it reads changed. - Lines 20 to 32: neither, so React doesn't render it at all: it calls attemptEarlyBailoutIfNoScheduledUpdate, which ends in bailoutOnAlreadyFinishedWork:
function bailoutOnAlreadyFinishedWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// ... (bookkeeping and profiling left out)
// Check if the children have any pending work.
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
// The children don't have any work either. We can skip them.
// ... (a note about future work left out)
if (current !== null) {
// Before bailing out, check if there are any context changes in
// the children.
lazilyPropagateParentContextChanges(current, workInProgress, renderLanes);
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
return null;
}
} else {
return null;
}
}
// This fiber doesn't have work, but its subtree does. Clone the child
// fibers and continue.
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}- Line 8 checks the fiber's
childLanes. Lines 11 to 20: if nothing below has an update for this render, it returnsnull. The loop completes this fiber at once and never visits anything below it. - Lines 25 and 26: if something below has one, it clones the children from the current tree, without calling the component, and returns the first child, so the loop goes down.
CartItem has the update in its lanes. Its parents have it in childLanes, so beginWork bails out on each one but still goes down. CartHeader, Cake and CartFooter have neither, so beginWork returns null for each, and nothing below them is visited.Now go back to the playground in the performUnitOfWork section and pick Tea's quantity changes. The root, App, the section and the ul bail out and go down; only Tea renders; CartHeader, Cake and CartFooter each take one step and are done. The props check is about the same object, not equal values: App didn't render, so its old elements, with their old props objects, are reused. When a parent renders, every child gets new props objects and renders too, as the when React renders again lesson showed, unless you use memo.
When the loop ends, the commit begins
Once completeUnitOfWork climbs past the root, the render returns to performWorkOnRoot, and the finished tree goes to the commit through a chain of functions in ReactFiberWorkLoop.js:
performWorkOnRoottakes the finished tree asroot.current.alternate(line 1222): the twin of the tree on screen.- finishConcurrentRender decides whether to commit now or wait, for example for a Suspense fallback. Despite its name, it handles synchronous renders too.
- completeRootWhenReady and completeRoot lead to commitRoot, which changes the DOM (commitMutationEffects) and then swaps the trees, at lines 4047 to 4051:
// The work-in-progress tree is now the current tree. This must come after
// the mutation phase, so that the previous tree is still current during
// componentWillUnmount, but before the layout phase, so that the finished
// work is current during componentDidMount/Update.
root.current = finishedWork;Line 5 is the switch from the fiber lesson. To see where the render ends and the commit starts, add an item to a cart that is already on the page. Click Add mint.
import { useState } from "react";import { clearLog, note, showLog } from "./spy.js"; function CartHeader() { note("render CartHeader"); return <h2>Your cart</h2>;} function CartItem({ name, price }) { note(`render CartItem ${name}`); return <li>{name + " $" + price}</li>;} export default function App() { note("render App"); const [items, setItems] = useState(["Tea", "Cake"]); function addMint() { clearLog(); setItems([...items, "Mint"]); setTimeout(showLog); } return ( <section> <CartHeader /> <ul> {items.map((name) => <CartItem key={name} name={name} price={3} />)} </ul> <button onClick={addMint} disabled={items.includes("Mint")}>Add mint</button> </section> );}Line 20 adds Mint, and App renders again with all its items. The log reads render App, render CartHeader, the three render CartItem lines, create <li>, and finally ul.appendChild(<li>). Mint's <li> is created in the render, by its completeWork, like before. But this time it goes into a <ul> that's already on the page, so React doesn't append it during the render. The fiber gets the Placement flag instead, and the commit does the append, after the whole render is done. The tree on screen never shows half a render. The commit phase lesson goes through the commit step by step.
Which loop rendered this component?
When a page feels slow, the first question is often whether a render ran in one long piece or in slices. The stack answers it: the loop that called your component is on it. Press each button.
import { startTransition, useState } from "react"; function loopOnTheStack() { Error.stackTraceLimit = 50; const lines = new Error().stack.split("\n"); const names = lines.map((line) => line.trim().replace(/^at /, "").split(/[ @(]/)[0]); const loops = ["workLoopSync", "workLoopConcurrentByScheduler"]; return loops.find((name) => names.includes(name)) ?? "neither";} function Probe({ round }) { const loop = loopOnTheStack(); return <p>Round {round} was rendered by {loop}.</p>;} export default function App() { const [round, setRound] = useState(0); return ( <> <button onClick={() => setRound(round + 1)}>After a click</button> <button onClick={() => startTransition(() => setRound(round + 1))}>In a transition</button> <button onClick={() => setTimeout(() => setRound(round + 1), 0)}>From a timer</button> <Probe round={round} /> </> );}Lines 3 to 9 make an Error without throwing it, cut each line of its stack down to a function's name (line 6), and look for the two loops (the stack trace API lesson explains stack and stackTraceLimit). Comparing names, not the whole text, matters here: the live preview loads your file from an address that holds its own source code, so the stack's text contains every word you wrote. The first render and After a click show workLoopSync. In a transition shows workLoopConcurrentByScheduler. From a timer shows workLoopSync: an update from a timer gets DefaultLane, which is blocking in React 19, so it can't be sliced. If a slow update from a timer or a fetch makes the page stutter, wrapping it in startTransition is what moves it to the slicing loop. These function names are React's internals, not an API: the production build shortens them, and any release can rename them. Read them to learn, never in an app's code.
The same names show up in the browser's Performance panel. Record a slow click, and you'll see one long task with performWorkOnRoot, workLoopSync, performUnitOfWork and beginWork inside it. A transition shows many short tasks instead. The profiling lesson shows how to record one.
- Calls your component function.
- Turns the elements a component returned into child fibers.
- Returns null for a fiber with no update and empty childLanes.
- Creates the DOM element for a new <li>.
- Appends a new element's children to it, off-screen.
- Adds the children's flags into subtreeFlags.
- Moves workInProgress to the child beginWork returned.
- Copies pendingProps into memoizedProps.
- Calls performUnitOfWork until workInProgress is null.
- Asks shouldYield whether to stop for the browser.
Put each job under the function in the work loop that does it.
Common mix-ups
- "React calls every component first, then creates the DOM." No. The two are mixed, depth first: an element is created as soon as everything below it is complete, as the overview's log shows.
- "React walks the tree with recursion." No. It uses a
whileloop and one variable,workInProgress, moved throughchild,siblingandreturn. - "Components that don't render aren't visited." No. beginWork still visits each fiber on the way to an update and bails out. Only subtrees with nothing in
childLanesare skipped whole. - "Every render can be paused." No. Only
workLoopConcurrentBySchedulerchecksshouldYield. Clicks, typing and timers render to the end withworkLoopSync. - "When React yields, the render starts over." No.
workInProgresskeeps its place, and the next slice goes on from it. React only starts over when something more urgent arrives. - "A time slice is one frame, about 16 ms." No. The scheduler's
frameYieldMsis 5. - "completeWork puts elements on the page." No. It builds them off the page. Only the commit attaches them to the document.
Practice exercises
This app imports the same spy.js. Predict the first three lines of its log, then press Edit & run to check.
import { useEffect } from "react";
import { note, showLog } from "./spy.js";
function Title() {
note("render Title");
return <h1>Tea shop</h1>;
}
function Badge() {
note("render Badge");
return <span>New</span>;
}
export default function App() {
note("render App");
useEffect(() => showLog(), []);
return (
<header>
<Title />
<Badge />
</header>
);
}import { useEffect } from "react";
import { note, showLog } from "./spy.js";
function Title() {
note("render Title");
return <h1>Tea shop</h1>;
}
function Badge() {
note("render Badge");
return <span>New</span>;
}
export default function App() {
note("render App");
useEffect(() => showLog(), []);
return (
<header>
<Title />
<Badge />
</header>
);
}beginWork renders App, goes down through the header to Title and renders it. The h1 has no child fiber, so completeWork creates it right away, before the loop moves on to Badge: render App, render Title, create <h1>.
The button saves the cart from a timer. Predict what it says after the save.
import { useState } from "react";
function loopOnTheStack() {
Error.stackTraceLimit = 50;
const lines = new Error().stack.split("\n");
const names = lines.map((line) => line.trim().replace(/^at /, "").split(/[ @(]/)[0]);
return names.includes("workLoopSync") ? "workLoopSync" : "workLoopConcurrentByScheduler";
}
export default function App() {
const [saved, setSaved] = useState(false);
const loop = loopOnTheStack();
return (
<button onClick={() => setTimeout(() => setSaved(true), 100)}>
{saved ? `Saved. This render ran in ${loop}.` : "Save the cart"}
</button>
);
}import { useState } from "react";
function loopOnTheStack() {
Error.stackTraceLimit = 50;
const lines = new Error().stack.split("\n");
const names = lines.map((line) => line.trim().replace(/^at /, "").split(/[ @(]/)[0]);
return names.includes("workLoopSync") ? "workLoopSync" : "workLoopConcurrentByScheduler";
}
export default function App() {
const [saved, setSaved] = useState(false);
const loop = loopOnTheStack();
return (
<button onClick={() => setTimeout(() => setSaved(true), 100)}>
{saved ? `Saved. This render ran in ${loop}.` : "Save the cart"}
</button>
);
}The update runs from a timer, so it gets DefaultLane. That lane is blocking in React 19, so performWorkOnRoot renders with renderRootSync, and the stack has workLoopSync.
Finish walk so it prints a begin line and a complete line for every fiber, in the work loop's order, with loops and one variable.
const app = { name: "App" };
const title = { name: "Title" };
const list = { name: "List" };
const tea = { name: "Tea" };
const cake = { name: "Cake" };
app.child = title;
title.return = app;
title.sibling = list;
list.return = app;
list.child = tea;
tea.return = list;
tea.sibling = cake;
cake.return = list;
// TODO: print "begin X" and "complete X" for every fiber, in the work
// loop's order, with a loop and one variable. No recursion.
function walk(root) {
}
walk(app);const app = { name: "App" };
const title = { name: "Title" };
const list = { name: "List" };
const tea = { name: "Tea" };
const cake = { name: "Cake" };
app.child = title;
title.return = app;
title.sibling = list;
list.return = app;
list.child = tea;
tea.return = list;
tea.sibling = cake;
cake.return = list;
function walk(root) {
let fiber = root;
while (fiber) {
console.log(`begin ${fiber.name}`);
if (fiber.child) {
fiber = fiber.child;
continue;
}
while (fiber) {
console.log(`complete ${fiber.name}`);
if (fiber === root) return;
if (fiber.sibling) {
fiber = fiber.sibling;
break;
}
fiber = fiber.return;
}
}
}
walk(app);The outer loop begins a fiber and goes down. The inner loop completes fibers, going across to a sibling or up to the parent, which is exactly what completeUnitOfWork does. It prints begin App, begin Title, complete Title, begin List, begin Tea, complete Tea, begin Cake, complete Cake, complete List, and last complete App.
Change workLoop so it also stops when shouldYield() returns true. Where does the first slice stop?
const app = { name: "App" };
const title = { name: "Title" };
const list = { name: "List" };
const tea = { name: "Tea" };
const cake = { name: "Cake" };
app.child = title;
title.return = app;
title.sibling = list;
list.return = app;
list.child = tea;
tea.return = list;
tea.sibling = cake;
cake.return = list;
let workInProgress = app;
let unitsDone = 0;
// One unit: go to the child, or else to the next sibling up the tree.
function performUnitOfWork(fiber) {
unitsDone += 1;
if (fiber.child) return fiber.child;
while (fiber) {
if (fiber.sibling) return fiber.sibling;
fiber = fiber.return;
}
return null;
}
function shouldYield() {
return unitsDone === 3;
}
// TODO: stop the loop when shouldYield() says so.
function workLoop() {
while (workInProgress !== null) {
workInProgress = performUnitOfWork(workInProgress);
}
}
workLoop();
console.log("paused at", workInProgress?.name);
unitsDone = 0; // a new time slice
workLoop();
console.log("finished:", workInProgress === null);const app = { name: "App" };
const title = { name: "Title" };
const list = { name: "List" };
const tea = { name: "Tea" };
const cake = { name: "Cake" };
app.child = title;
title.return = app;
title.sibling = list;
list.return = app;
list.child = tea;
tea.return = list;
tea.sibling = cake;
cake.return = list;
let workInProgress = app;
let unitsDone = 0;
// One unit: go to the child, or else to the next sibling up the tree.
function performUnitOfWork(fiber) {
unitsDone += 1;
if (fiber.child) return fiber.child;
while (fiber) {
if (fiber.sibling) return fiber.sibling;
fiber = fiber.return;
}
return null;
}
function shouldYield() {
return unitsDone === 3;
}
function workLoop() {
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
workLoop();
console.log("paused at", workInProgress?.name);
unitsDone = 0; // a new time slice
workLoop();
console.log("finished:", workInProgress === null);With !shouldYield() in the condition, the first slice stops after three units: App, Title and List. workInProgress already points at List's child, so it prints paused at Tea, and the second slice finishes from there.
Type a letter: the log shows three renders. Change the code so a keystroke renders only one component, and the log shows one line.
import { useState } from "react";import { clearLog, note, showLog } from "./spy.js"; function Header() { note("render Header"); return <h1>Tea shop</h1>;} function ProductList() { note("render ProductList"); return ( <ul> <li>Green tea</li> <li>Black tea</li> </ul> );} export default function App() { note("render App"); const [query, setQuery] = useState(""); function type(event) { clearLog(); setQuery(event.target.value); setTimeout(showLog); } return ( <> <Header /> <input value={query} onChange={type} placeholder="Search teas" /> <ProductList /> </> );}import { useState } from "react";import { clearLog, note, showLog } from "./spy.js"; function Header() { note("render Header"); return <h1>Tea shop</h1>;} function ProductList() { note("render ProductList"); return ( <ul> <li>Green tea</li> <li>Black tea</li> </ul> );} function SearchBox() { note("render SearchBox"); const [query, setQuery] = useState(""); function type(event) { clearLog(); setQuery(event.target.value); setTimeout(showLog); } return <input value={query} onChange={type} placeholder="Search teas" />;} export default function App() { note("render App"); return ( <> <Header /> <SearchBox /> <ProductList /> </> );}Now the update is in SearchBox's lanes. App, Header and ProductList have no update, and only SearchBox renders: beginWork bails out on App and goes down through its childLanes, and skips Header and ProductList. The log shows render SearchBox.
Finish complete so every fiber's childLanes holds the lanes of everything below it. What does it print for App?
const tea = { name: "Tea", lanes: 0b10, childLanes: 0, children: [] };
const mint = { name: "Mint", lanes: 0b100000000, childLanes: 0, children: [] };
const list = { name: "List", lanes: 0, childLanes: 0, children: [tea, mint] };
const title = { name: "Title", lanes: 0, childLanes: 0, children: [] };
const app = { name: "App", lanes: 0, childLanes: 0, children: [title, list] };
// TODO: set each fiber's childLanes to the lanes and childLanes of all its
// children, combined with |. Children first, as completeWork runs.
function complete(fiber) {
}
complete(app);
console.log(app.childLanes);const tea = { name: "Tea", lanes: 0b10, childLanes: 0, children: [] };
const mint = { name: "Mint", lanes: 0b100000000, childLanes: 0, children: [] };
const list = { name: "List", lanes: 0, childLanes: 0, children: [tea, mint] };
const title = { name: "Title", lanes: 0, childLanes: 0, children: [] };
const app = { name: "App", lanes: 0, childLanes: 0, children: [title, list] };
function complete(fiber) {
let childLanes = 0;
for (const child of fiber.children) {
complete(child);
childLanes |= child.lanes | child.childLanes;
}
fiber.childLanes = childLanes;
}
complete(app);
console.log(app.childLanes);complete finishes each child first, as completeWork runs after the children, then merges child.lanes | child.childLanes, as bubbleProperties does. List gets 2 | 256 = 258, and App gets List's 258: it prints 258.
Check your understanding
Question 1 of 7In the overview's log, why does
create <h2>come beforerender CartItem Tea?Choose an answer to see the explanation.
Question 2 of 7What does performUnitOfWork do when beginWork returns null?
Choose an answer to see the explanation.
Question 3 of 7What does this loop print? It walks three fibers the way the work loop does.
Read the code, then predictconst a = { name: "A" }; const b = { name: "B", return: a }; const c = { name: "C", return: a }; a.child = b; b.sibling = c; const order = []; let fiber = a; while (fiber) { order.push("begin " + fiber.name); if (fiber.child) { fiber = fiber.child; continue; } while (fiber) { order.push("complete " + fiber.name); if (fiber.sibling) { fiber = fiber.sibling; break; } fiber = fiber.return; } } console.log(order.join(", "));Choose an answer to see the explanation.
Question 4 of 7In React 19.3.1, which update is rendered by workLoopConcurrentByScheduler?
Choose an answer to see the explanation.
Question 5 of 7When does workLoopConcurrentByScheduler stop before the tree is done?
Choose an answer to see the explanation.
Question 6 of 7Only Tea's
CartItemhas new state. Where does beginWork return null and skip the whole subtree?Choose an answer to see the explanation.
Question 7 of 7When does the FiberRoot's
currentmove to the finished tree?Choose an answer to see the explanation.
Key takeaways
- A render is a
whileloop over one variable,workInProgress, in ReactFiberWorkLoop.js. performUnitOfWorkcallsbeginWorkand goes down to the child it returns. With no child,completeUnitOfWorkcompletes fibers and moves to a sibling, or up.- beginWork calls your components and makes child fibers. completeWork creates DOM elements off the page and collects flags and lanes.
- Clicks, typing and timers use
workLoopSync. Transitions useworkLoopConcurrentByScheduler, which stops every 5 ms whenshouldYield()says so, and goes on from the same fiber. - beginWork bails out on a fiber with no update. If
childLanesis empty too, it skips the whole subtree. - When the loop finishes, the commit changes the DOM and makes the finished tree current.
The work loop begins fibers on the way down and completes them on the way up, one unit at a time.
One pointer is all it keeps, so it can stop between any two fibers.
Coming next: Reconciliation and the diffing algorithm.