cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

The commit phase

Read commitRoot in React 19.3.1: the mutation, layout and passive phases, the flags that steer them, when refs attach, and why you never see half an update.

By the end, you can
  • 01
    Put a commit in orderSay what happens in the before mutation, mutation and layout phases and in the passive effects, and predict the order of logs for a click.
  • 02
    Read the flagsExplain how Placement, Update, ChildDeletion, Ref and Passive tell the commit what to do, and how subtreeFlags let it skip whole branches.
  • 03
    Explain the timing guaranteesSay why the DOM never shows half an update, when refs are null and when they are attached, and when useEffect runs before or after the paint.

One click, in the order React commits it

Reconciliation ended with a tree of fibers marked with flags: insert this, update that, delete those. Nothing on the page has changed yet. The commit phase is where React reads those flags and changes the DOM, then attaches refs and runs your effects. This lesson reads it in ReactFiberCommitWork.js and ReactFiberWorkLoop.js, at React 19.3.1.

You know the outline from Render and commit and useLayoutEffect. Here is a cart badge that logs every step React takes for it. Read the console, then press Add one.

A cart badge that logs each step of a commitPop out in the code editor (opens in a new tab)JSX
import { useEffect, useLayoutEffect, useState } from "react"; export default function App() {  const [count, setCount] = useState(0);  console.log(`render: count is ${count}`);   useLayoutEffect(() => {    console.log(`layout effect: count is ${count}`);    return () => console.log(`layout cleanup: count was ${count}`);  });   useEffect(() => {    console.log(`effect: count is ${count}`);    return () => console.log(`effect cleanup: count was ${count}`);  });   function badgeRef(node) {    console.log(`ref callback: the badge shows ${node.textContent}`);    return () => console.log("ref cleanup");  }   return (    <p>      Items in cart: <strong ref={badgeRef}>{count}</strong>{" "}      <button onClick={() => setCount(count + 1)}>Add one</button>    </p>  );}
ResultLoading…

Line 5 logs every render. Lines 7 to 10 are a layout effect with a cleanup, and lines 12 to 15 a regular effect with a cleanup. Lines 17 to 20 are a ref callback: React calls it with the <strong> on line 24 once the element is on the page, and calls the function it returns when it detaches the ref. Because badgeRef is a new function on every render, React detaches the old one and attaches the new one in every commit.

When the page loads, the console shows the render twice, then ref callback: the badge shows 0, layout effect: count is 0 and effect: count is 0, and then the cleanups and setups once more: in development, StrictMode renders twice and runs a new component's refs and effects twice. Now press Add one. After the two renders, the console shows exactly this, in this order:

  1. ref cleanup and layout cleanup: count was 0: the old ref and the old layout effect are undone.
  2. ref callback: the badge shows 1: the DOM already says 1 when the new ref is attached.
  3. layout effect: count is 1: layout effects run after the refs.
  4. effect cleanup: count was 0 and effect: count is 1: the regular effect comes last.

That order isn't a habit of this example. It's the shape of every commit, and the rest of this lesson reads the code that makes it.

Left, a card: the finished tree, with flags. An arrow leads into a window labelled commitRoot, one task, holding three cards in order: before (the before mutation phase), mutation, layout. Under them, a label: root.current = finishedWork, the swap that happens between mutation and layout. An arrow leads out to a card: the browser can paint. Another arrow leads to a last card: passive effects, useEffect.finished treewith flagscommitRootbeforemutationlayoutroot.current = finishedWorkone task: no paint in betweenbrowser paintspassive effectsuseEffecta click runs passive effects before the paint instead
The render phase hands commitRoot a finished tree with flags. Before mutation, mutation and layout run one after another in one task, so the browser can't paint in between. Passive effects, your useEffect code, run after that, usually in a task of their own.
Real-life analogyRefitting a shop overnight

A shop changes its layout overnight. During the day, the staff draw up a plan, and they can tear it up and start again. At night, with the shutters down, they take a photo of the old shelves, move everything, and check every label before they lift the shutters. Customers only ever see the old shop or the new one. The newsletter about the new layout goes out later.

In real life: The plan drawn up during the day, which can still change
In React: The render phase, which makes no DOM changes
In real life: A photo of the shelves before anyone touches them
In React: The before mutation phase, getSnapshotBeforeUpdate
In real life: Moving the shelves with the shutters down
In React: The mutation phase, changing the DOM
In real life: Checking the labels line up before opening
In React: The layout phase: refs and layout effects
In real life: Sending the newsletter after the doors open
In React: Passive effects, your useEffect code
The main idea

The commit phase applies a finished render to the page. commitRoot runs three phases in one go: before mutation (read the old DOM), mutation (change the DOM, detach refs, run layout cleanups), and layout (attach refs, run layout effects). Flags on each fiber say what to do, and subtreeFlags say where to look. Passive effects, your useEffect code, run after that.

commitRoot runs the phases in a fixed order

When the work loop finishes a render, it has a complete work-in-progress tree, called finishedWork. It hands that tree to commitRoot. The real function is about 200 lines, mostly profiling and View Transitions, so this version is shortened: // … marks lines left out. The source is written in Flow, a type checker like TypeScript, so the text after a colon, like : Fiber, is a type.

commitRoot, shortened from ReactFiberWorkLoop.js lines 3725 to 3922JavaScript
function commitRoot(
  root: FiberRoot,
  finishedWork: Fiber,
  lanes: Lanes,
  // …
) {
  // …
  if (
    // …
    (finishedWork.subtreeFlags & passiveSubtreeMask) !== NoFlags ||
    (finishedWork.flags & passiveSubtreeMask) !== NoFlags
  ) {
    // …
      scheduleCallback(NormalSchedulerPriority, () => {
        // …
        flushPassiveEffects();
        // …
        return null;
      });
    // …
  }
  // …
  // The commit phase is broken into several sub-phases. We do a separate pass
  // of the effect list for each phase: all mutation effects come before all
  // layout effects, and so on.
  // …
  if (subtreeHasBeforeMutationEffects || rootHasBeforeMutationEffect) {
    // …
      // The first phase a "before mutation" phase. We use this phase to read the
      // state of the host tree right before we mutate it. This is where
      // getSnapshotBeforeUpdate is called.
      commitBeforeMutationEffects(root, finishedWork, lanes);
    // …
  }
  // …
    // Flush synchronously.
    flushMutationEffects();
    flushLayoutEffects();
    // Skip flushAfterMutationEffects
    flushSpawnedWork();
  // …
}

In plain words, part by part:

  1. Lines 8 to 21 come first, before any DOM change: if any fiber in the tree has passive effects, React schedules flushPassiveEffects as a separate task with scheduleCallback. It doesn't run them now.
  2. Lines 23 to 25: React's own comment. Each phase is a separate pass over the tree, and each pass finishes before the next.
  3. Lines 27 to 33: the before mutation phase. As the comment says, it reads the DOM right before React changes it; this is where getSnapshotBeforeUpdate runs.
  4. Lines 36 to 40: with no View Transition to start, the other phases run straight away, one after the other: mutation, layout, then flushSpawnedWork, which finishes the commit.

The mutation phase ends with one line that matters a lot. Here is flushMutationEffects, shortened:

flushMutationEffects, shortened from ReactFiberWorkLoop.js lines 4009 to 4053JavaScript
function flushMutationEffects(): void {
  // …
  const root = pendingEffectsRoot;
  const finishedWork = pendingFinishedWork;
  const lanes = pendingEffectsLanes;
  const subtreeMutationHasEffects =
    (finishedWork.subtreeFlags & MutationMask) !== NoFlags;
  const rootMutationHasEffect = (finishedWork.flags & MutationMask) !== NoFlags;

  if (subtreeMutationHasEffects || rootMutationHasEffect) {
    // …
      // The next phase is the mutation phase, where we mutate the host tree.
      commitMutationEffects(root, finishedWork, lanes);
    // …
  }

  // 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;
  pendingEffectsStatus = PENDING_LAYOUT_PHASE;
}
  1. Lines 6 to 8 check the root's flags with MutationMask. If nothing in the tree needs a DOM change, the whole pass is skipped.
  2. Line 13 is the mutation phase itself: commitMutationEffects walks the tree and changes the DOM.
  3. Line 21 swaps the trees: the finished work-in-progress tree becomes root.current, the tree on the screen. The comment on lines 17 to 20 says why it's exactly here: cleanups for removed components still see the old tree, and componentDidMount and layout effects see the new one.

How to read the table: each row is one phase, what React does in it, and what your code sees if it runs then.

The phases of a commit
PhaseWhat React doesWhat your code sees
Before mutationCalls getSnapshotBeforeUpdate on class components that have one, and gets View Transitions ready.The old DOM, still on the page.
MutationRemoves deleted nodes, inserts and moves placed ones, applies changed props and text, detaches changed refs, and runs layout effect cleanups.A DOM in the middle of changing. Refs to removed nodes are already null.
LayoutAttaches refs, runs layout effects, componentDidMount and componentDidUpdate, children before parents.The whole new DOM, before the browser has painted it.
Passive effectsRuns every useEffect cleanup that is due, then every useEffect setup.The new DOM. After a click, before the paint; otherwise usually after it.

Flags say what to do; subtreeFlags say where to look

The commit doesn't compare anything. It reads flags: bits on each fiber, set during the render. The Fiber lesson introduced them: each flag is a single bit in a number, so a fiber can carry several at once, combined with | and tested with &, the bitwise operators from the other operators lesson. Here are the ones this lesson uses, and the masks the commit tests them with, from ReactFiberFlags.js:

Some flags and the three masks, from ReactFiberFlags.js lines 18 to 132JavaScript
export const NoFlags = /*                      */ 0b0000000000000000000000000000000;
export const PerformedWork = /*                */ 0b0000000000000000000000000000001;
export const Placement = /*                    */ 0b0000000000000000000000000000010;
// …
export const Update = /*                       */ 0b0000000000000000000000000000100;
// …
export const ChildDeletion = /*                */ 0b0000000000000000000000000010000;
// …
export const Ref = /*                          */ 0b0000000000000000000001000000000;
export const Snapshot = /*                     */ 0b0000000000000000000010000000000;
export const Passive = /*                      */ 0b0000000000000000000100000000000;
// …
export const MutationMask =
  Placement |
  Update |
  ChildDeletion |
  ContentReset |
  Ref |
  Hydrating |
  Visibility |
  FormReset;
export const LayoutMask = Update | Callback | Ref | Visibility;

// TODO: Split into PassiveMountMask and PassiveUnmountMask
export const PassiveMask = Passive | Visibility | ChildDeletion;
  1. Lines 1 to 11: each flag is one bit, written in binary (0b). Placement is 2, Update is 4, ChildDeletion is 16, Ref 512, Snapshot 1024 and Passive 2048.
  2. Lines 13 to 21: MutationMask joins every flag the mutation phase cares about. Line 22: LayoutMask, for the layout phase. Line 25: PassiveMask, for passive effects.

Reconciliation sets Placement and ChildDeletion. Update on a DOM element comes from completeWork, in updateHostComponent:

updateHostComponent, ReactFiberCompleteWork.js lines 474 to 481JavaScript
    const oldProps = current.memoizedProps;
    if (oldProps === newProps) {
      // In mutation mode, this is sufficient for a bailout because
      // we won't touch this node even if children changed.
      return;
    }

    markUpdate(workInProgress);

Line 2 compares the props objects, not their values. A component that renders again makes new elements, with new props objects, so each of its DOM elements gets Update (line 8), even if nothing in it changed. Update means "compare the props in the commit", not "the page will change".

Checking every fiber for flags would mean walking the whole tree on every commit. So each fiber also has subtreeFlags: all the flags of everything below it, as the Fiber lesson's picture showed. As the render finishes each fiber, React adds its children's flags into it, in bubbleProperties:

bubbleProperties, shortened from ReactFiberCompleteWork.js lines 837 to 856JavaScript
      let child = completedWork.child;
      while (child !== null) {
        newChildLanes = mergeLanes(
          newChildLanes,
          mergeLanes(child.lanes, child.childLanes),
        );

        subtreeFlags |= child.subtreeFlags;
        subtreeFlags |= child.flags;
        // …
        child = child.sibling;
      }
    }

    completedWork.subtreeFlags |= subtreeFlags;

Lines 2 and 11 walk the children. Lines 8 and 9 add each child's own flags and its subtreeFlags into one number, and line 15 stores it. Lines 3 to 6 do the same for pending work (childLanes, from the Reconciliation lesson's bailouts). So if a fiber's subtreeFlags has no bit from MutationMask, nothing below it needs a DOM change, and the mutation phase can skip the whole branch.

A fiber tree after Rename Cake. App, with subtreeFlags Update, has three children: Header, CartList and Footer. CartList has subtreeFlags Update, and below it a ul with Update, and two li, Tea and Lemon cake, each with Update. Header's h1 and Footer's p are crossed out: the walk never reaches them.AppsubtreeFlags: UpdateHeaderCartListFooter<h1><p>never reachednever reached<ul> Update2 × li: Updatethe walk goes down here
After Rename Cake, only the CartList branch has flags. Every fiber above it has Update in its subtreeFlags, so the commit walks down that branch. Header and Footer have no subtreeFlags, so the walk never goes inside them.

Try it on a small shop. CartList owns the cart; Header and Footer don't read it. Each button below changes the cart, and the tree shows the flags each fiber ends up with, and how far the mutation phase walks.

Playground: follow the flags down the tree
The shop the walk is aboutJSX
function Header() {
  return <h1>Tea Shop</h1>;
}

function CartList() {
  const [items, setItems] = useState([
    { id: "tea", name: "Tea" },
    { id: "cake", name: "Cake" },
  ]);
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

function Footer() {
  return <p>Free delivery over $20</p>;
}

export default function App() {
  return (
    <main>
      <Header />
      <CartList />
      <Footer />
    </main>
  );
}
The fibers after the renderMutationMask
  1. Appflags: nonesubtreeFlags: Updatevisited, and the walk goes inside
  2. <main>flags: nonesubtreeFlags: Updatevisited, and the walk goes inside
  3. Headerflags: nonesubtreeFlags: nonevisited; nothing below, so its children are skipped
  4. <h1> Tea Shopflags: nonesubtreeFlags: nonenever reached
  5. CartListflags: nonesubtreeFlags: Updatevisited, and the walk goes inside
  6. <ul>flags: UpdatesubtreeFlags: Updatevisited, and the walk goes inside
  7. <li> Teaflags: UpdatesubtreeFlags: nonevisited; nothing below, so its children are skipped
  8. <li> Lemon cakeflags: UpdatesubtreeFlags: nonevisited; nothing below, so its children are skipped
  9. Footerflags: nonesubtreeFlags: nonevisited; nothing below, so its children are skipped
  10. <p> Free delivery over $20flags: nonesubtreeFlags: nonenever reached
Try it yourself
Rename Cake: what the commit finds

CartList rendered, so the <ul> and both <li> got new props objects, and completeWork marked each with Update. Their subtreeFlags add up on the way to the root. Header and Footer didn't render: their flags are empty, so the walk doesn't go inside them. In the commit, only Cake's Update changes the page: the others compare their props and find nothing to change.

Each button stands for one setItems call inside CartList. The flags and subtreeFlags are the ones React 19.3.1 leaves on these fibers (the lesson's test reads them from React's own fibers); the walk is a model of recursivelyTraverseMutationEffects, written for this lesson. Reset goes back to Rename Cake.

Whatever you pick, Header and Footer are visited (their parent has work below it) but the walk never goes inside them, so their <h1> and <p> are never looked at. How to read the table: each row is a flag, what sets it, and what the commit does with it.

The flags, who sets them, and what the commit does
FlagWho sets itWhat the commit does
PlacementReconciliation, for a new fiber or one that moved.The mutation phase inserts or moves its DOM nodes.
UpdatecompleteWork, when a DOM element's props object changed; a useLayoutEffect whose dependencies changed.The mutation phase compares and applies props or text; the layout phase runs the layout effect.
ChildDeletiondeleteChild, on the parent of a deleted fiber.The mutation phase removes the deleted subtree, detaches its refs, and runs its cleanups.
RefmarkRef, when the ref is new or different.The mutation phase detaches the old ref; the layout phase attaches the new one.
PassiveA useEffect whose dependencies changed.React schedules the passive effects, and runs them after the commit.
SnapshotA class component with getSnapshotBeforeUpdate, on an update.The before mutation phase calls getSnapshotBeforeUpdate.

The mutation phase: remove, insert, update, detach

commitMutationEffects starts at the root and calls commitMutationEffectsOnFiber on each fiber it reaches. Every case of that function begins by handing the fiber's children to recursivelyTraverseMutationEffects, shown as it is:

recursivelyTraverseMutationEffects, ReactFiberCommitWork.js lines 2046 to 2068JavaScript
function recursivelyTraverseMutationEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  lanes: Lanes,
) {
  // Deletions effects can be scheduled on any fiber type. They need to happen
  // before the children effects have fired.
  const deletions = parentFiber.deletions;
  if (deletions !== null) {
    for (let i = 0; i < deletions.length; i++) {
      const childToDelete = deletions[i];
      commitDeletionEffects(root, parentFiber, childToDelete);
    }
  }

  if (parentFiber.subtreeFlags & (MutationMask | Cloned)) {
    let child = parentFiber.child;
    while (child !== null) {
      commitMutationEffectsOnFiber(child, root, lanes);
      child = child.sibling;
    }
  }
}
  1. Lines 8 to 14: first, the deletions that reconciliation put on this fiber. commitDeletionEffects removes each deleted subtree's DOM nodes, detaches its refs, and runs its layout cleanups and componentWillUnmount.
  2. Lines 16 to 22: then, only if subtreeFlags has a mutation bit, each child in turn. (Cloned is for renderers that copy nodes instead of changing them, like React Native's; the DOM doesn't use it.)

After its children, a fiber handles itself. This is the case for a DOM element, from commitMutationEffectsOnFiber, shortened:

The HostComponent case of commitMutationEffectsOnFiber, shortened from ReactFiberCommitWork.js lines 2274 to 2344JavaScript
    case HostComponent: {
      // …
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      // …
      commitReconciliationEffects(finishedWork, lanes);

      if (flags & Ref) {
        if (!offscreenSubtreeWasHidden && current !== null) {
          safelyDetachRef(current, current.return);
        }
      }
      // …
        if (flags & Update) {
          const instance: Instance = finishedWork.stateNode;
          if (instance != null) {
            // Commit the work prepared earlier.
            // …
            const newProps = finishedWork.memoizedProps;
            const oldProps =
              current !== null ? current.memoizedProps : newProps;
            commitHostUpdate(finishedWork, newProps, oldProps);
          }
        }
      // …
    }
  1. Line 3: the children first, deletions included.
  2. Line 5: then this element's own Placement, if it has one: insert it, or move it.
  3. Lines 7 to 11: a Ref flag means the ref changed, so the old ref is detached now: React calls your ref cleanup, or the old callback with null, or sets ref.current to null.
  4. Lines 13 to 23: an Update flag hands the old and new props to commitHostUpdate, which compares them and changes only the attributes, styles and text that differ.

Placement is handled in commitReconciliationEffects, shortened:

commitReconciliationEffects, shortened from ReactFiberCommitWork.js lines 2845 to 2864JavaScript
function commitReconciliationEffects(
  finishedWork: Fiber,
  committedLanes: Lanes,
) {
  // Placement effects (insertions, reorders) can be scheduled on any fiber
  // type. They needs to happen after the children effects have fired, but
  // before the effects on this fiber have fired.
  const flags = finishedWork.flags;
  if (flags & Placement) {
    commitHostPlacement(finishedWork);
    // Clear the "placement" from effect tag so that we know that this is
    // inserted, before any life-cycles like componentDidMount gets called.
    // …
    finishedWork.flags &= ~Placement;
  }
  // …
}

Line 10 inserts the fiber's DOM nodes: commitHostPlacement finds the next sibling that is already in place and calls insertBefore, or appendChild when there is none. Line 14 clears the flag, so the fiber counts as inserted from here on. A function component has no DOM node of its own, but its layout cleanups run in this phase too, from its case:

The function component case of commitMutationEffectsOnFiber, shortened from ReactFiberCommitWork.js lines 2088 to 2128JavaScript
    case FunctionComponent:
    case ForwardRef:
    case MemoComponent:
    case SimpleMemoComponent: {
      // …
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork, lanes);

      if (flags & Update) {
        commitHookEffectListUnmount(
          HookInsertion | HookHasEffect,
          finishedWork,
          finishedWork.return,
        );
        // TODO: Use a commitHookInsertionUnmountEffects wrapper to record timings.
        commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork);
        commitHookLayoutUnmountEffects(
          finishedWork,
          finishedWork.return,
          HookLayout | HookHasEffect,
        );
      }
      break;
    }

Lines 6 and 7 are the same two steps: children, then placement. When the component's effects changed (Update), lines 10 to 16 run the cleanups and setups of useInsertionEffect, and lines 17 to 21 run the cleanups of its layout effects. Their setups wait for the layout phase. That's why the overview logged ref cleanup, from the <strong> below App, before layout cleanup: count was 0, from App itself.

Watch the order on a real page. This cart logs every DOM change with a MutationObserver. Press Swap Cake for Milk and Jam.

A cart that logs the DOM changes of one commitPop out in the code editor (opens in a new tab)JSX
import { useLayoutEffect, useRef, useState } from "react"; export default function App() {  const [items, setItems] = useState(["Tea", "Cake"]);  const cartRef = useRef(null);   useLayoutEffect(() => {    const observer = new MutationObserver((records) => {      console.log(`MutationObserver: ${records.length} changes, all at once`);      for (const record of records) {        for (const node of record.removedNodes) console.log(`  removed ${node.textContent}`);        for (const node of record.addedNodes) console.log(`  added ${node.textContent}`);        if (record.type === "characterData") console.log(`  text is now ${record.target.data}`);      }    });    observer.observe(cartRef.current, { childList: true, subtree: true, characterData: true });    return () => observer.disconnect();  }, []);   useLayoutEffect(() => {    console.log(`layout effect: the list has ${cartRef.current.querySelectorAll("li").length} items`);  });   const hasCake = items.includes("Cake");  return (    <>      <button onClick={() => setItems(hasCake ? ["Tea", "Milk", "Jam"] : ["Tea", "Cake"])}>        {hasCake ? "Swap Cake for Milk and Jam" : "Put Cake back"}      </button>      <div ref={cartRef}>        <p>Items: {items.length}</p>        <ul>          {items.map((name) => (            <li key={name}>{name}</li>          ))}        </ul>      </div>    </>  );}
ResultLoading…

Lines 7 to 18 start a MutationObserver on the cart once, and lines 9 to 13 log what it reports. Lines 20 to 22 are a layout effect that counts the list items. Line 27 swaps the list. The console shows layout effect: the list has 3 items, then MutationObserver: 4 changes, all at once, then text is now 3, removed Cake, added Milk and added Jam.

The order follows the walk. The <p> on line 31 comes before the <ul>, so its text changes first. At the <ul>, the deletion comes before its children, so Cake is removed before Milk and Jam are added. And the observer only hears about it afterwards, all four changes in one batch: the next section explains why.

Why the screen never shows half an update

In the cart above, the count changed before the list did. For a moment, the DOM said Items: 3 next to the old list. You never see that moment, for two reasons.

  • The render phase can pause, and in a transition React can throw a half-finished render away and start again. But rendering never touches the DOM: it only builds fibers and flags.
  • The commit can't pause. commitRoot runs the before mutation, mutation and layout phases one after another, in one piece of JavaScript work, a task, with no point where it stops to let anything else in.

A browser only paints between tasks, never in the middle of one (the event loop lesson shows why). So the screen goes from the old page to the new page in one step. Even a MutationObserver has to wait: it collects the changes and reports them in a microtask after the commit's work, which is why the cart's observer logged after the layout effect, with all four changes at once. By the time any code outside React can look, the whole commit is done.

When the commit is over, React says so to its scheduler. This line in flushSpawnedWork runs right after the layout phase:

requestPaint in flushSpawnedWork, ReactFiberWorkLoop.js lines 4187 to 4189JavaScript
  // Tell Scheduler to yield at the end of the frame, so the browser has an
  // opportunity to paint.
  requestPaint();

requestPaint asks the scheduler to stop running React's queued work at the end of this task, so the browser gets a chance to paint before the next one. What runs in that next task is the subject of the passive effects section.

The layout phase: attach refs, run layout effects

After the mutation phase and the root.current swap, commitLayoutEffects walks the tree again, this time using LayoutMask to skip branches. Each fiber goes through commitLayoutEffectOnFiber, shortened here to three of its cases:

commitLayoutEffectOnFiber, shortened from ReactFiberCommitWork.js lines 600 to 934JavaScript
function commitLayoutEffectOnFiber(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {
  // …
  const flags = finishedWork.flags;
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      recursivelyTraverseLayoutEffects(
        finishedRoot,
        finishedWork,
        committedLanes,
      );
      if (flags & Update) {
        commitHookLayoutEffects(finishedWork, HookLayout | HookHasEffect);
      }
      break;
    }
    case ClassComponent: {
      recursivelyTraverseLayoutEffects(
        finishedRoot,
        finishedWork,
        committedLanes,
      );
      if (flags & Update) {
        commitClassLayoutLifecycles(finishedWork, current);
      }
      // …
      if (flags & Ref) {
        safelyAttachRef(finishedWork, finishedWork.return);
      }
      break;
    }
    // …
    case HostComponent: {
      recursivelyTraverseLayoutEffects(
        finishedRoot,
        finishedWork,
        committedLanes,
      );
      // …
      if (flags & Ref) {
        safelyAttachRef(finishedWork, finishedWork.return);
      }
      break;
    }
    // …
  }
  // …
}
  1. Lines 13 to 17, 24 to 28 and 40 to 44: every case starts with recursivelyTraverseLayoutEffects, so the children are done before the fiber itself. A parent's layout effect can rely on its children's DOM and refs.
  2. Lines 18 to 20: a function component with a changed layout effect runs its setup now.
  3. Lines 29 to 31: a class component runs componentDidMount or componentDidUpdate here, in commitClassLayoutLifecycles. They are the class version of a layout effect.
  4. Lines 33 to 35 and 46 to 48: a Ref flag attaches the ref. safelyAttachRef calls your ref callback with the DOM node, or sets ref.current.

Where does the Ref flag come from? During the render, markRef compares the new ref with the old one, in ReactFiberBeginWork.js:

markRef, ReactFiberBeginWork.js lines 1426 to 1429JavaScript
    if (current === null || current.ref !== ref) {
      // Schedule a Ref effect
      workInProgress.flags |= Ref | RefStatic;
    }

A new element (current === null) or a different ref gets the flag. A ref object from useRef is the same object every render, so it's attached once. The overview's badgeRef is a new function every render, so it's detached and attached in every commit.

Three boxes named ref, left to right. During the render: it holds the old strong element showing 0. In the mutation phase: it holds null. In the layout phase: it holds the strong element showing 1. Arrows lead from each box to the next.<strong>0refrenderstill the old nodenullrefmutationdetached<strong>1reflayoutattached to the new node
During a render, a ref still holds what the last commit gave it. The mutation phase detaches it (your ref cleanup, or a call with null); the layout phase attaches it to the committed node, before any layout effect reads it.

Here is the order for a list, a class component and their parent. Press Add a cake.

Children before parents in the layout phasePop out in the code editor (opens in a new tab)JSX
import { Component, useLayoutEffect, useRef, useState } from "react"; function Row({ name }) {  useLayoutEffect(() => {    console.log(`Row ${name}: layout effect`);  }, [name]);  return <li>{name}</li>;} class Total extends Component {  componentDidUpdate() {    console.log(`Total: componentDidUpdate, ${this.props.count} items`);  }  render() {    return <p>Total items: {this.props.count}</p>;  }} export default function App() {  const [items, setItems] = useState(["Tea"]);  const listRef = useRef(null);   useLayoutEffect(() => {    console.log(`App: layout effect, the list has ${listRef.current.children.length} rows`);  }, [items]);   return (    <>      <button onClick={() => setItems([...items, `Cake ${items.length}`])}>Add a cake</button>      <ul ref={listRef}>        {items.map((name) => (          <Row key={name} name={name} />        ))}      </ul>      <Total count={items.length} />    </>  );}
ResultLoading…

Row (lines 3 to 8) logs from a layout effect when its name is new. Total (lines 10 to 17) is a class component that logs from componentDidUpdate. App logs from its own layout effect on lines 23 to 25, reading the <ul> through listRef.

The console shows Row Cake 1: layout effect, then Total: componentDidUpdate, 2 items, then App: layout effect, the list has 2 rows. The new row first, because it's deepest; then Total, the next sibling; then App, last, with its ref already attached and both rows in the DOM. One more Row Cake 1: layout effect follows: StrictMode runs a newly added component's effects twice in development.

Passive effects: after the commit, usually in a task of their own

"Passive effects" is React's name for useEffect. You saw in commitRoot that React schedules them before the mutation phase starts. When their task comes, flushPassiveEffectsImpl runs them in two passes:

flushPassiveEffectsImpl, ReactFiberWorkLoop.js lines 4788 to 4795JavaScript
  commitPassiveUnmountEffects(root.current);
  commitPassiveMountEffects(
    root,
    root.current,
    lanes,
    transitions,
    pendingEffectsRenderEndTime,
  );

Line 1 runs every passive cleanup that is due, across the whole tree. Lines 2 to 8 then run every setup. So no effect in the tree starts until every old one has stopped. Both passes use root.current: by now, the finished tree is the current one.

When is that task? It depends on what caused the render. A click is a discrete event, and the useLayoutEffect lesson showed this part of flushSpawnedWork:

Passive effects after a discrete render, ReactFiberWorkLoop.js lines 4316 to 4329JavaScript
  // If the passive effects are the result of a discrete render, flush them
  // synchronously at the end of the current task so that the result is
  // immediately observable. Otherwise, we assume that they are not
  // order-dependent and do not need to be observed by external systems, so we
  // can wait until after paint.
  // TODO: We can optimize this by not scheduling the callback earlier. Since we
  // currently schedule the callback in multiple places, will wait until those
  // are consolidated.
  if (
    includesSyncLane(pendingEffectsLanes) &&
    (disableLegacyMode || root.tag !== LegacyRoot)
  ) {
    flushPendingEffects();
  }

A render caused by a click uses the sync lane, React's most urgent priority, so lines 9 to 14 run the passive effects at the end of the commit's own task, before the browser can paint. For anything else, like a timer or a transition, they wait for the task scheduleCallback set up, and requestPaint lets the browser paint first if a frame is due. Try both here, and use the boxes on the right of the console to tell the lines apart.

When do effects run: after a click, after a timerPop out in the code editor (opens in a new tab)JSX
import { useEffect, useLayoutEffect, useState } from "react"; function slowDown(ms) {  const start = performance.now();  while (performance.now() - start < ms) {    // Busy on purpose, so the render takes a while.  }} export default function App() {  const [count, setCount] = useState(0);  const [slow, setSlow] = useState(false);  if (slow) slowDown(40);   useLayoutEffect(() => {    if (count === 0) return;    console.log(`layout effect: ${count}`);    queueMicrotask(() => console.log("microtask: the commit's task is over"));    requestAnimationFrame(() => console.log("frame: the browser is about to paint"));  }, [count]);   useEffect(() => {    if (count === 0) return;    console.log(`effect: ${count}`);  }, [count]);   return (    <>      <p>Count: {count}</p>      <button onClick={() => setCount(count + 1)}>Add now</button>      <button onClick={() => setTimeout(() => setCount((n) => n + 1), 0)}>Add from a timer</button>      <label>        <input type="checkbox" checked={slow} onChange={(event) => setSlow(event.target.checked)} />        Slow render      </label>    </>  );}
ResultLoading…

Lines 15 to 20 log the layout effect, then ask for two markers: a microtask on line 18, which runs as soon as the current task ends, and an animation frame on line 19, which runs just before the browser paints. Lines 22 to 25 log the effect. Line 30 changes the count from a click; line 31 changes it from a timer. Line 13 makes every render take 40 milliseconds when Slow render is ticked.

  • Add now: layout effect, effect, then the microtask and the frame. The effect ran inside the commit's task, before the paint.
  • Add from a timer: layout effect, the microtask, then effect. The commit's task ended first, and the effect ran in a task of its own. The frame usually comes after the effect anyway: a task that is ready runs before the next frame is due.
  • Tick Slow render and press Add from a timer again: now the frame usually comes before the effect. The slow render made the frame overdue, so the browser painted in the gap React left.
Two timelines. Top, after a click: one task holding render, commit and effects, then the paint. Bottom, after a timer: one task holding render and commit, then a gap where the browser may paint, then a second task holding effects.After a clickrendercommiteffectsone taskpaintAfter a timer or a transitionrendercommitone taskpaint, if dueeffectsa later task
After a click, React runs the passive effects at the end of the commit's own task, before the browser can paint. After a timer or a transition, they wait for a later task, and the browser can paint first if a frame is due.

So "useEffect runs after the paint" is a good rule of thumb, not a promise. React promises the order: effects never run before the DOM changes and layout effects, and a click's effects never wait for a later task. When code must run before the paint, it belongs in a layout effect.

Keeping a chat in place when older messages load

A chat loads older messages above the ones you are reading. If nothing corrects it, the list grows upwards under you and your place jumps. The fix needs the scroll position from before the new messages arrive, and a correction after they arrive but before the paint. That's the before mutation phase and the layout phase. Scroll the chat down a little, note the top message, then press Load older messages.

A chat that keeps your place with getSnapshotBeforeUpdatePop out in the code editor (opens in a new tab)JSX
import { Component, createRef, useState } from "react"; class ChatLog extends Component {  listRef = createRef();   getSnapshotBeforeUpdate(prevProps) {    const list = this.listRef.current;    console.log(`before mutation: ${list.children.length} messages on the page`);    if (prevProps.messages.length < this.props.messages.length) {      return list.scrollHeight - list.scrollTop;    }    return null;  }   componentDidUpdate(prevProps, prevState, distanceFromBottom) {    const list = this.listRef.current;    console.log(`layout: ${list.children.length} messages on the page`);    if (distanceFromBottom !== null) {      list.scrollTop = list.scrollHeight - distanceFromBottom;    }  }   render() {    return (      <ul ref={this.listRef} className="chat">        {this.props.messages.map((message) => (          <li key={message.id}>{message.text}</li>        ))}      </ul>    );  }} const firstMessages = Array.from({ length: 8 }, (_, i) => ({ id: 100 + i, text: `Message ${100 + i}` })); export default function App() {  const [messages, setMessages] = useState(firstMessages);   function loadOlder() {    const first = messages[0].id;    const older = Array.from({ length: 4 }, (_, i) => ({ id: first - 4 + i, text: `Message ${first - 4 + i}` }));    setMessages([...older, ...messages]);  }   return (    <>      <button onClick={loadOlder}>Load older messages</button>      <ChatLog messages={messages} />    </>  );}
ResultLoading…

getSnapshotBeforeUpdate (lines 6 to 13) runs in the before mutation phase. Line 8 logs before mutation: 8 messages on the page: the DOM is still the old one. Line 10 measures how far the view is from the bottom of the list and returns it. componentDidUpdate (lines 15 to 21) runs in the layout phase with that value as its third argument. Line 17 logs layout: 12 messages on the page, and line 19 scrolls so the view is the same distance from the bottom as before. The browser paints once, with the view where you left it.

Press Edit & run and delete lines 18 to 20, then load more messages: the view jumps. The before mutation phase calls the snapshot from this case:

The ClassComponent case of commitBeforeMutationEffectsOnFiber, ReactFiberCommitWork.js lines 524 to 531JavaScript
    case ClassComponent: {
      if ((flags & Snapshot) !== NoFlags) {
        if (current !== null) {
          commitClassSnapshot(finishedWork, current);
        }
      }
      break;
    }

Line 2 checks the Snapshot flag, which a class with getSnapshotBeforeUpdate gets on an update, and line 3 skips first mounts: there is no old DOM to read. There is no hook for this phase yet, which is why this example is a class. Now sort what you have seen by the phase it runs in:

Which phase runs it?
  • getSnapshotBeforeUpdate reads the old scroll height
  • React removes the <li> of a deleted row
  • React inserts a new <li> with insertBefore or appendChild
  • A useLayoutEffect cleanup from the last render
  • A ref callback gets the new DOM node
  • componentDidMount of a class component
  • A useEffect setup that subscribes to a chat server
  • The useEffect cleanup that unsubscribes
Try it yourself
0 of 8 correct

Sort each piece of work into the phase of the commit that runs it.

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

Common mix-ups

  • "React compares the DOM in the commit phase." No. The comparing happened during the render. The commit reads flags and applies them; commitHostUpdate only compares one element's old and new props.
  • "Layout effect cleanups run in the layout phase." No. They run in the mutation phase, as React walks the tree. Only the setups wait for the layout phase.
  • "useEffect always runs after the browser paints." No. After a click, React runs it before the paint. Otherwise it runs in a later task, and the browser paints in between only if a frame is due.
  • "A ref is filled in when the element is created." No. It's attached in the layout phase, after the DOM is complete, and detached in the mutation phase when the element changes or goes away.
  • "Parents commit before their children." No. Each phase handles a fiber's children first, so a child's layout effect runs before its parent's.
  • "The browser could paint between two DOM changes in one commit." No. The mutation and layout phases run in one task, and the browser only paints between tasks.

Practice exercises

Each exercise shows its console when you press Edit & run. Write down your prediction before you run it.

Exercise 1 · Warm-upPredict the letters

The page has loaded. You press Like once. In what order do the letters appear in the console?

import { useEffect, useLayoutEffect, useState } from "react";

export default function App() {
  const [liked, setLiked] = useState(false);

  useEffect(() => {
    console.log("A");
  }, [liked]);

  useLayoutEffect(() => {
    console.log("B");
  }, [liked]);

  return (
    <button ref={(node) => console.log(node ? "C" : "D")} onClick={() => setLiked(!liked)}>
      {liked ? "Liked" : "Like"}
    </button>
  );
}

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

    Exercise 2 · Warm-upChild or parent first?

    You press Raise the price. Which line comes first in the console: Price: layout effect or App: layout effect?

    import { useLayoutEffect, useState } from "react";
    
    function Price({ amount }) {
      useLayoutEffect(() => {
        console.log("Price: layout effect");
      }, [amount]);
      return <strong>${amount}</strong>;
    }
    
    export default function App() {
      const [amount, setAmount] = useState(3);
    
      useLayoutEffect(() => {
        console.log("App: layout effect");
      }, [amount]);
    
      return (
        <p>
          Tea: <Price amount={amount} />{" "}
          <button onClick={() => setAmount(amount + 1)}>Raise the price</button>
        </p>
      );
    }

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

      Exercise 3 · PracticeSave the draft without crashing

      This name editor should log the draft when you press Done, but it crashes. Fix the cleanup. After your fix, what does the console say when you press Done?

      import { useLayoutEffect, useRef, useState } from "react"; export default function App() {  const [editing, setEditing] = useState(false);  const inputRef = useRef(null);   useLayoutEffect(() => {    if (!editing) return;    return () => {      console.log(`Saving draft: ${inputRef.current.value}`);    };  }, [editing]);   return (    <>      {editing ? <input ref={inputRef} defaultValue="Asha" /> : <p>Name: Asha</p>}      <button onClick={() => setEditing(!editing)}>{editing ? "Done" : "Edit name"}</button>    </>  );}
      ResultLoading…

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

        Exercise 4 · PracticeWhich branch does the walk enter?

        Finish the loop so it prints, for each fiber, whether the mutation walk goes inside it. Which fiber does the walk go inside?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const Placement = 0b10;
        const Update = 0b100;
        const ChildDeletion = 0b10000;
        const Ref = 0b1000000000;
        const MutationMask = Placement | Update | ChildDeletion | Ref;
        
        // The flags on three fibers after a render.
        const fibers = [
          { name: "Header", flags: 0, subtreeFlags: 0 },
          { name: "CartList", flags: 0, subtreeFlags: Update | Placement },
          { name: "Footer", flags: Ref, subtreeFlags: 0 },
        ];
        
        for (const fiber of fibers) {
          // Print each fiber's name, and whether the mutation walk goes inside it.
        }

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

          Exercise 5 · ChallengePredict a removal

          You press Remove the first. Which line comes right after App: layout effect, 1 items?

          import { useEffect, useLayoutEffect, useState } from "react";
          
          function Row({ name }) {
            useLayoutEffect(() => {
              return () => console.log(`${name}: layout cleanup`);
            }, [name]);
            useEffect(() => {
              return () => console.log(`${name}: effect cleanup`);
            }, [name]);
            return <li>{name}</li>;
          }
          
          export default function App() {
            const [items, setItems] = useState(["Tea", "Cake"]);
            useLayoutEffect(() => {
              console.log(`App: layout effect, ${items.length} items`);
            }, [items]);
            useEffect(() => {
              console.log(`App: effect, ${items.length} items`);
            }, [items]);
          
            return (
              <>
                <button onClick={() => setItems(items.slice(1))}>Remove the first</button>
                <ul>
                  {items.map((name) => (
                    <Row key={name} name={name} />
                  ))}
                </ul>
              </>
            );
          }

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

            Exercise 6 · ChallengeMeasure before the first paint

            The panel shows its own width, but for a moment it can say 0. Change one hook so the browser's first paint shows the right width. What does the panel say the first time the browser paints it?

            import { useEffect, useRef, useState } from "react"; export default function App() {  const [open, setOpen] = useState(false);  const [width, setWidth] = useState(0);  const panelRef = useRef(null);   useEffect(() => {    if (open) setWidth(panelRef.current.offsetWidth);  }, [open]);   return (    <>      <button onClick={() => setTimeout(() => setOpen(true), 0)}>Open the panel</button>      {open && (        <div ref={panelRef} style={{ width: 240 }}>          <p>Panel width: {width}</p>        </div>      )}    </>  );}
            ResultLoading…

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

              Check your understanding

              For each question, name the phase first: before mutation, mutation, layout, or passive.

              The commit phase quiz · 8 questionsScore: first tries count
              1. Question 1 of 8In what order do the phases of one commit run?

                Choose an answer to see the explanation.

              2. Question 2 of 8This component starts on step 1, then you press Next. What does it log, in order?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { useEffect, useLayoutEffect, useState } from "react";
                
                export default function App() {
                  const [step, setStep] = useState(1);
                  useEffect(() => {
                    console.log(`effect ${step}`);
                    return () => console.log(`effect cleanup ${step}`);
                  }, [step]);
                  useLayoutEffect(() => {
                    console.log(`layout ${step}`);
                    return () => console.log(`layout cleanup ${step}`);
                  }, [step]);
                  return <button onClick={() => setStep(step + 1)}>Next</button>;
                }

                Choose an answer to see the explanation.

              3. Question 3 of 8Where is root.current = finishedWork in the commit, and why there?

                Choose an answer to see the explanation.

              4. Question 4 of 8Only CartList's state changed. Why doesn't the mutation phase go inside Header?

                Choose an answer to see the explanation.

              5. Question 5 of 8A parent and its child both have a layout effect. Which logs first when the tree mounts?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { useLayoutEffect } from "react";
                
                function Child() {
                  useLayoutEffect(() => {
                    console.log("Child");
                  }, []);
                  return <span>child</span>;
                }
                
                export default function Parent() {
                  useLayoutEffect(() => {
                    console.log("Parent");
                  }, []);
                  return <Child />;
                }

                Choose an answer to see the explanation.

              6. Question 6 of 8In a layout effect cleanup, you read a ref to a child <input> that this update removes. What does it hold?

                Choose an answer to see the explanation.

              7. Question 7 of 8A setState inside setTimeout causes a render. When do its useEffect setups usually run?

                Choose an answer to see the explanation.

              8. Question 8 of 8Why can't the browser paint a page that's half updated?

                Choose an answer to see the explanation.

              Key takeaways

              • commitRoot schedules the passive effects, then runs the before mutation, mutation and layout phases in one task. The browser can't paint until they are all done, so it never shows half an update.
              • The commit reads flags left by the render: Placement, Update, ChildDeletion, Ref, Passive. subtreeFlags collect everything below a fiber, so whole branches with no work are skipped.
              • The mutation phase handles deletions first, then children, then the fiber itself: placement, ref detach, prop updates. Layout cleanups run here too.
              • Between mutation and layout, root.current becomes the finished tree. The layout phase then attaches refs and runs layout effects and class lifecycles, children before parents.
              • Passive effects run every cleanup, then every setup: at the end of the commit's task after a click, otherwise in a later task, with a chance for the browser to paint first.

              Remember the one-liner.
              Before mutation reads, mutation writes, layout attaches and measures, all in one task; passive effects come after.

              Coming next: How hooks work inside. The effects this lesson ran were stored on each fiber by its hooks; the next lesson opens ReactFiberHooks.js to see how.

              CompleteFrontend Clear concepts. Working examples.