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.
- 01Put 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.
- 02Read the flagsExplain how Placement, Update, ChildDeletion, Ref and Passive tell the commit what to do, and how subtreeFlags let it skip whole branches.
- 03Explain 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.
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> );}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:
ref cleanupandlayout cleanup: count was 0: the old ref and the old layout effect are undone.ref callback: the badge shows 1: the DOM already says 1 when the new ref is attached.layout effect: count is 1: layout effects run after the refs.effect cleanup: count was 0andeffect: 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.
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.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 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.
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:
- Lines 8 to 21 come first, before any DOM change: if any fiber in the tree has passive effects, React schedules
flushPassiveEffectsas a separate task withscheduleCallback. It doesn't run them now. - Lines 23 to 25: React's own comment. Each phase is a separate pass over the tree, and each pass finishes before the next.
- Lines 27 to 33: the before mutation phase. As the comment says, it reads the DOM right before React changes it; this is where
getSnapshotBeforeUpdateruns. - 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:
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;
}- 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. - Line 13 is the mutation phase itself:
commitMutationEffectswalks the tree and changes the DOM. - 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, andcomponentDidMountand 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.
| Phase | What React does | What your code sees |
|---|---|---|
| Before mutation | Calls getSnapshotBeforeUpdate on class components that have one, and gets View Transitions ready. | The old DOM, still on the page. |
| Mutation | Removes 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. |
| Layout | Attaches refs, runs layout effects, componentDidMount and componentDidUpdate, children before parents. | The whole new DOM, before the browser has painted it. |
| Passive effects | Runs 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:
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;- Lines 1 to 11: each flag is one bit, written in binary (
0b).Placementis 2,Updateis 4,ChildDeletionis 16,Ref512,Snapshot1024 andPassive2048. - Lines 13 to 21:
MutationMaskjoins 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:
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:
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.
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.
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>
);
}MutationMask- Appflags:
nonesubtreeFlags:Updatevisited, and the walk goes inside - <main>flags:
nonesubtreeFlags:Updatevisited, and the walk goes inside - Headerflags:
nonesubtreeFlags:nonevisited; nothing below, so its children are skipped - <h1> Tea Shopflags:
nonesubtreeFlags:nonenever reached - CartListflags:
nonesubtreeFlags:Updatevisited, and the walk goes inside - <ul>flags:
UpdatesubtreeFlags:Updatevisited, and the walk goes inside - <li> Teaflags:
UpdatesubtreeFlags:nonevisited; nothing below, so its children are skipped - <li> Lemon cakeflags:
UpdatesubtreeFlags:nonevisited; nothing below, so its children are skipped - Footerflags:
nonesubtreeFlags:nonevisited; nothing below, so its children are skipped - <p> Free delivery over $20flags:
nonesubtreeFlags:nonenever reached
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.
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.
| Flag | Who sets it | What the commit does |
|---|---|---|
Placement | Reconciliation, for a new fiber or one that moved. | The mutation phase inserts or moves its DOM nodes. |
Update | completeWork, 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. |
ChildDeletion | deleteChild, on the parent of a deleted fiber. | The mutation phase removes the deleted subtree, detaches its refs, and runs its cleanups. |
Ref | markRef, when the ref is new or different. | The mutation phase detaches the old ref; the layout phase attaches the new one. |
Passive | A useEffect whose dependencies changed. | React schedules the passive effects, and runs them after the commit. |
Snapshot | A 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:
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;
}
}
}- Lines 8 to 14: first, the deletions that reconciliation put on this fiber.
commitDeletionEffectsremoves each deleted subtree's DOM nodes, detaches its refs, and runs its layout cleanups andcomponentWillUnmount. - Lines 16 to 22: then, only if
subtreeFlagshas a mutation bit, each child in turn. (Clonedis 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:
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);
}
}
// …
}- Line 3: the children first, deletions included.
- Line 5: then this element's own
Placement, if it has one: insert it, or move it. - Lines 7 to 11: a
Refflag means the ref changed, so the old ref is detached now: React calls your ref cleanup, or the old callback withnull, or setsref.currenttonull. - Lines 13 to 23: an
Updateflag hands the old and new props tocommitHostUpdate, which compares them and changes only the attributes, styles and text that differ.
Placement is handled in commitReconciliationEffects, shortened:
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:
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.
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> </> );}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.
commitRootruns 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:
// 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:
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;
}
// …
}
// …
}- 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. - Lines 18 to 20: a function component with a changed layout effect runs its setup now.
- Lines 29 to 31: a class component runs
componentDidMountorcomponentDidUpdatehere, incommitClassLayoutLifecycles. They are the class version of a layout effect. - Lines 33 to 35 and 46 to 48: a
Refflag attaches the ref.safelyAttachRefcalls your ref callback with the DOM node, or setsref.current.
Where does the Ref flag come from? During the render, markRef compares the new ref with the old one, in ReactFiberBeginWork.js:
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.
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.
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} /> </> );}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:
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:
// 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.
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> </> );}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, theneffect. 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.
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.
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} /> </> );}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:
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:
getSnapshotBeforeUpdatereads the old scroll height- React removes the
<li>of a deleted row - React inserts a new
<li>withinsertBeforeorappendChild - A
useLayoutEffectcleanup from the last render - A ref callback gets the new DOM node
componentDidMountof a class component- A
useEffectsetup that subscribes to a chat server - The
useEffectcleanup that unsubscribes
Sort each piece of work into the phase of the commit that runs it.
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;
commitHostUpdateonly 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.
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>
);
}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> );}The mutation phase calls the old callback with null, which logs D. The layout phase calls the new callback with the button (C), then runs the layout effect (B). The useEffect runs last (A). So: D C B A.
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>
);
}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> );}Price is a child of App. The layout phase finishes the children first, so Price: layout effect comes before App: layout effect, even though App rendered first.
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> </> );}import { useLayoutEffect, useRef, useState } from "react"; export default function App() { const [editing, setEditing] = useState(false); const inputRef = useRef(null); useLayoutEffect(() => { if (!editing) return; const input = inputRef.current; return () => { console.log(`Saving draft: ${input.value}`); }; }, [editing]); return ( <> {editing ? <input ref={inputRef} defaultValue="Asha" /> : <p>Name: Asha</p>} <button onClick={() => setEditing(!editing)}>{editing ? "Done" : "Edit name"}</button> </> );}The <input> is deleted in the mutation phase, and its deletion sets inputRef.current to null before App's layout cleanup runs. Keeping the node in input during the setup lets the cleanup read it, so it logs Saving draft: Asha.
Finish the loop so it prints, for each fiber, whether the mutation walk goes inside it. Which fiber does the walk go inside?
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.
}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) {
const goesInside = (fiber.subtreeFlags & MutationMask) !== 0;
console.log(`${fiber.name}: ${goesInside ? "goes inside" : "skips its children"}`);
}Only CartList's subtreeFlags has bits from MutationMask, so only its children are visited. Footer has a Ref flag of its own, which the walk still handles when it reaches Footer, but nothing below Footer needs work, so the walk doesn't go inside.
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>
</>
);
}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> </> );}The log is Tea: layout cleanup, App: layout effect, 1 items, Tea: effect cleanup, App: effect, 1 items. Tea's layout cleanup runs while the mutation phase removes it; App's layout effect runs in the layout phase. The passive pass runs Tea's cleanup before App's setup.
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> )} </> );}import { useLayoutEffect, useRef, useState } from "react"; export default function App() { const [open, setOpen] = useState(false); const [width, setWidth] = useState(0); const panelRef = useRef(null); useLayoutEffect(() => { 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> )} </> );}With useLayoutEffect, React measures the panel in the layout phase, and the setWidth there renders and commits again before the task ends. The first paint already says Panel width: 240. With useEffect, the browser can paint Panel width: 0 first.
Check your understanding
For each question, name the phase first: before mutation, mutation, layout, or passive.
Question 1 of 8In what order do the phases of one commit run?
Choose an answer to see the explanation.
Question 2 of 8This component starts on step 1, then you press Next. What does it log, in order?
Read the code, then predictimport { 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.
Question 3 of 8Where is
root.current = finishedWorkin the commit, and why there?Choose an answer to see the explanation.
Question 4 of 8Only CartList's state changed. Why doesn't the mutation phase go inside Header?
Choose an answer to see the explanation.
Question 5 of 8A parent and its child both have a layout effect. Which logs first when the tree mounts?
Read the code, then predictimport { 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.
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.
Question 7 of 8A
setStateinsidesetTimeoutcauses a render. When do itsuseEffectsetups usually run?Choose an answer to see the explanation.
Question 8 of 8Why can't the browser paint a page that's half updated?
Choose an answer to see the explanation.
Key takeaways
commitRootschedules 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.subtreeFlagscollect 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.currentbecomes 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.