cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

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.

By the end, you can
  • 01
    Trace a renderPredict the order in which React calls beginWork and completeWork on a tree, and when it creates each DOM element.
  • 02
    Tell the two loops apartSay which updates use workLoopSync and which use the loop that checks shouldYield, and what happens when it yields.
  • 03
    Explain 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.

One render of a small cart, as a logPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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:

  1. render App, then render CartHeader, then create <h2>. React doesn't call every component first. As soon as the h2 has nothing below it, React creates its DOM element.
  2. Only then render CartItem Tea and its <li>, then Cake and its <li>.
  3. create <ul> comes after both items, followed by two ul.appendChild(<li>): a parent's element is made after its children's, and they go straight into it.
  4. create <section> is the last element, and it gets the h2, the ul and the p.
  5. 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.

The cart's fibers as a tree: App, then section, then CartHeader, ul and CartFooter, then h2, CartItem Tea, CartItem Cake and p, then two li. Under each card, its begin and complete numbers. App is begin 1, complete 11. CartHeader is begin 3, complete 2. h2 is begin 4, complete 1. ul is begin 5, complete 7. CartItem Tea is begin 6, complete 4. Its li is begin 7, complete 3. CartItem Cake is begin 8, complete 6. Its li is begin 9, complete 5. CartFooter is begin 10, complete 9. p is begin 11, complete 8. section is begin 2, complete 10.Appbegin 1, complete 11sectionbegin 2, complete 10CartHeaderbegin 3, complete 2ulbegin 5, complete 7CartFooterbegin 10, complete 9h2begin 4, complete 1CartItem Teabegin 6, complete 4CartItem Cakebegin 8, complete 6pbegin 11, complete 8libegin 7, complete 3libegin 9, complete 5
The order of the eleven 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.
Real-life analogyAn inspector walking a building

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 main idea

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:

performWorkOnRoot picks a loop, ReactFiberWorkLoop.js lines 1165 to 1181 (shortened)JavaScript
  // 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);
  1. 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: SyncLane for clicks and typing, InputContinuousLane for events like scrolling, and DefaultLane for everything else outside a transition (plus their hydration twins and an experimental gesture lane). The lanes lesson covers them all.
  2. Line 9 adds one case, rendering ahead of time while something is suspended; the concurrent rendering lesson covers it.
  3. Lines 11 to 13 call renderRootConcurrent when time slicing is on, and renderRootSync when 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:

prepareFreshStack sets up the loop, ReactFiberWorkLoop.js lines 2255 to 2257JavaScript
  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:

workLoopSync in ReactFiberWorkLoop.js, lines 2772 to 2777JavaScript
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:

performUnitOfWork in ReactFiberWorkLoop.js (shortened)JavaScript
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;
  }
}
  1. Line 5 takes the fiber's alternate: the copy on screen, or null on the first render. beginWork compares the two.
  2. Line 9 calls beginWork, which returns the next fiber to work on: this fiber's first child, or null.
  3. Line 10 records that this render used pendingProps: they become memoizedProps.
  4. Lines 11 to 13: no child, so this fiber's part of the tree is done. completeUnitOfWork completes it and picks the next fiber.
  5. Lines 14 and 15: there is a child, so workInProgress moves down to it, and the loop's next turn begins it.
A flow chart. beginWork of the fiber, top left. If it returns a child, an arrow goes down to: go down, workInProgress is the child. If it returns null, an arrow goes right to completeWork of the fiber. From completeWork, one arrow labeled a sibling goes to: go across, workInProgress is the sibling. Another arrow labeled no sibling goes to: go up, complete the return fiber.beginWork(fiber)returns a childgo down: the childthe loop begins it nextreturns nullcompleteWork(fiber)a siblinggo across: siblingthe loop begins it nextno siblinggo up: the returncomplete it, and checkfor its siblingAt the root, workInProgress becomes null and the render is done.
One unit of work. 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.

Playground: step through the work loop
The fibersstep 0 of 27
  1. HostRootworkInProgress
  2. StrictMode
  3. App
  4. section
  5. CartHeader
  6. h2
  7. ul
  8. CartItem Tea
  9. li
  10. CartItem Cake
  11. li
  12. CartFooter
  13. p
The log so far0 lines

The renders and created elements appear here, as in the overview's log.

Step 0 of 27
workInProgress = HostRoot

prepareFreshStack has pointed workInProgress at the root. Press Step to run one beginWork or completeWork at a time.

A model written for this lesson, not a view inside React: it walks the overview's cart in the same order React does, and the lesson's test checks its renders and created elements against React's real log. The slices mode stops after every three units of work; React stops after about 5 ms.

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:

beginWork picks the work for each tag, ReactFiberBeginWork.js (shortened)JavaScript
  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);
  1. Line 1 clears the fiber's lanes: whatever update it had is being handled now.
  2. Line 3 switches on the work tag from the fiber lesson.
  3. 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.
  4. 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:

completeUnitOfWork in ReactFiberWorkLoop.js (shortened)JavaScript
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;
  }
}
  1. Lines 4 and 5 start a loop at the fiber that has no child.
  2. Line 12 calls completeWork on 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.
  3. Lines 20 to 25: if the fiber has a sibling, workInProgress moves there and the function returns, so the work loop begins the sibling next.
  4. 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.
  5. Lines 33 to 36: climbing past the root leaves workInProgress as null, and the render is marked complete.

For a new DOM element, completeWork is where it's made:

completeWork creates a new DOM element, ReactFiberCompleteWork.js (shortened)JavaScript
    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);
  1. Lines 6 to 12 call createInstance, the DOM host config's function (ReactFiberConfigDOM.js, line 534), which calls document.createElement. That's the create line in the log.
  2. Line 16, appendAllChildren, appends the children's DOM nodes, which already exist because the children completed first. Each one is a call to appendInitialChild, the ul.appendChild(<li>) lines in the log. The new element isn't on the page yet.
  3. Line 17 stores the element in the fiber's stateNode.
  4. Line 19, bubbleProperties, collects the children's notes into this fiber:
bubbleProperties collects lanes and flags, ReactFiberCompleteWork.js (shortened)JavaScript
  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.

beginWork and completeWork
The questionbeginWorkcompleteWork
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.jsReactFiberCompleteWork.js

workLoopSync and the loop that yields

renderRootConcurrent runs a second loop, workLoopConcurrentByScheduler. Compare it with workLoopSync:

workLoopConcurrentByScheduler in ReactFiberWorkLoop.js, lines 3072 to 3079JavaScript
/** @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:

shouldYieldToHost in Scheduler.js, lines 459 to 472JavaScript
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;
}
  1. Lines 2 to 5: if React has asked for a paint, which it does after a commit, yield at once.
  2. Line 6 measures how long this slice has been running.
  3. Lines 7 to 11: under frameInterval, keep going. Lines 12 and 13: past it, yield.

frameInterval starts at frameYieldMs, from SchedulerFeatureFlags.js, line 11:

The time slice, SchedulerFeatureFlags.js line 11JavaScript
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.

Two timelines. Top, labeled a click, workLoopSync: one long card, render without a break, then a commit card. Bottom, labeled a transition, workLoopConcurrentByScheduler: eight short cards of 5 ms with gaps between them, then a commit card.a click: workLoopSyncrender, without a breakcommitThe browser can't paint or run a click until the end.a transition: workLoopConcurrentByScheduler5 ms5 ms5 ms5 ms5 ms5 ms5 ms5 mscommitIn each gap, the browser paints and runs clicks.
The same render, two ways. 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.

The same 100 slow rows, rendered at once or in a transitionPop out in the code editor (opens in a new tab)JSX
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} />    </>  );}
ResultLoading…

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:

renderRootConcurrent picks its loop, ReactFiberWorkLoop.js lines 3007 to 3018JavaScript
      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 two loops
The questionworkLoopSyncworkLoopConcurrentByScheduler
Who calls it?renderRootSyncrenderRootConcurrent
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.

Only Tea's state changesPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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:

The start of beginWork, ReactFiberBeginWork.js (shortened)JavaScript
  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,
        );
      }
  1. Lines 1 to 3: on an update there is a current fiber, so React compares its old props with the new ones.
  2. Lines 5 to 12: different props objects, so this fiber has work to do.
  3. Lines 16 to 19: the same props object, so React asks checkScheduledUpdateOrContext whether the fiber's own lanes hold an update for this render, or a context it reads changed.
  4. Lines 20 to 32: neither, so React doesn't render it at all: it calls attemptEarlyBailoutIfNoScheduledUpdate, which ends in bailoutOnAlreadyFinishedWork:
bailoutOnAlreadyFinishedWork in ReactFiberBeginWork.js (shortened)JavaScript
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;
}
  1. Line 8 checks the fiber's childLanes. Lines 11 to 20: if nothing below has an update for this render, it returns null. The loop completes this fiber at once and never visits anything below it.
  2. 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.
The same cart tree after Tea's quantity changes. App, section and ul: bails out, goes down. CartItem Tea: lanes, renders. Its li: new props, Update. CartHeader, CartItem Cake and CartFooter are faded: skips its subtree. h2, Cake's li and p are faded: not visited.Appbails out, goes downsectionbails out, goes downCartHeaderskips its subtreeulbails out, goes downCartFooterskips its subtreeh2not visitedCartItem Tealanes: rendersCartItem Cakeskips its subtreepnot visitedlinew props: Updatelinot visited
Only Tea's 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:

  1. performWorkOnRoot takes the finished tree as root.current.alternate (line 1222): the twin of the tree on screen.
  2. finishConcurrentRender decides whether to commit now or wait, for example for a Suspense fallback. Despite its name, it handles synchronous renders too.
  3. completeRootWhenReady and completeRoot lead to commitRoot, which changes the DOM (commitMutationEffects) and then swaps the trees, at lines 4047 to 4051:
The commit swaps the trees, ReactFiberWorkLoop.js lines 4047 to 4051JavaScript
  // 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.

A new item: created in the render, attached in the commitPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

Which loop is on the stack?Pop out in the code editor (opens in a new tab)JSX
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} />    </>  );}
ResultLoading…

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.

Which function does this job?
  • 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.
Try it yourself
0 of 10 correct

Put each job under the function in the work loop that does it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 while loop and one variable, workInProgress, moved through child, sibling and return.
  • "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 childLanes are skipped whole.
  • "Every render can be paused." No. Only workLoopConcurrentByScheduler checks shouldYield. Clicks, typing and timers render to the end with workLoopSync.
  • "When React yields, the render starts over." No. workInProgress keeps 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 frameYieldMs is 5.
  • "completeWork puts elements on the page." No. It builds them off the page. Only the commit attaches them to the document.

Practice exercises

Exercise 1 · Warm-upPredict the start of the log

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>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWhich loop renders a timer's update?

    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>
      );
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWrite the walk

      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.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeStop the loop, then go on

        Change workLoop so it also stops when shouldYield() returns true. Where does the first slice stop?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeLet beginWork bail out

          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 />    </>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeCollect lanes like completeWork

            Finish complete so every fiber's childLanes holds the lanes of everything below it. What does it print for App?

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            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);

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The work loop quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In the overview's log, why does create <h2> come before render CartItem Tea?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does performUnitOfWork do when beginWork returns null?

                Choose an answer to see the explanation.

              3. Question 3 of 7What does this loop print? It walks three fibers the way the work loop does.

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              4. Question 4 of 7In React 19.3.1, which update is rendered by workLoopConcurrentByScheduler?

                Choose an answer to see the explanation.

              5. Question 5 of 7When does workLoopConcurrentByScheduler stop before the tree is done?

                Choose an answer to see the explanation.

              6. Question 6 of 7Only Tea's CartItem has new state. Where does beginWork return null and skip the whole subtree?

                Choose an answer to see the explanation.

              7. Question 7 of 7When does the FiberRoot's current move to the finished tree?

                Choose an answer to see the explanation.

              Key takeaways

              • A render is a while loop over one variable, workInProgress, in ReactFiberWorkLoop.js.
              • performUnitOfWork calls beginWork and goes down to the child it returns. With no child, completeUnitOfWork completes 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 use workLoopConcurrentByScheduler, which stops every 5 ms when shouldYield() says so, and goes on from the same fiber.
              • beginWork bails out on a fiber with no update. If childLanes is 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.

              CompleteFrontend Clear concepts. Working examples.