Commit Phase - Mutation, Layout, Passive Effects
completeRoot → commitRoot applies a finished work-in-progress tree to the DOM synchronously and without yielding, in fixed sub-phases. Before mutation reads DOM state…
- Published
- Reading time
- 12 min read
From render to commit
When the render loop finishes (Work Loop), performWorkOnRoot → finishConcurrentRender → completeRootWhenReady → completeRoot → commitRoot. Older articles call this commitRootImpl. In 19.3.1 the split is completeRoot (prep) / commitRoot (the phases), and each phase is its own flushXxx function driven by a small state machine.12
completeRoot starts with:
do {
flushPendingEffects();
} while (pendingEffectsStatus !== NO_PENDING_EFFECTS);So before committing a new tree, React flushes any passive effects from the previous commit that haven't run yet. Effects from commit N always run before commit N+1's DOM mutations. It then stashes pendingFinishedWork, pendingEffectsRoot, pendingEffectsLanes etc. in module state for the phase functions to use.3
The phase state machine:4
const NO_PENDING_EFFECTS = 0;
const PENDING_MUTATION_PHASE = 1;
const PENDING_LAYOUT_PHASE = 2;
const PENDING_AFTER_MUTATION_PHASE = 3;
const PENDING_SPAWNED_WORK = 4;
const PENDING_PASSIVE_PHASE = 5;
const PENDING_GESTURE_MUTATION_PHASE = 6;
const PENDING_GESTURE_ANIMATION_PHASE = 7;The reason for having explicit states instead of one straight-line function is View Transitions: the mutation and layout phases may have to run inside a document.startViewTransition() callback, i.e. later and asynchronously, and anything that needs to "flush now" (a flushSync, a new render) can call flushPendingEffects() to drive the machine to completion from wherever it is.5
commitRoot
// ReactFiberWorkLoop.js (simplified)
function commitRoot(root, finishedWork, lanes, /* ... */) {
let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
remainingLanes = mergeLanes(remainingLanes, getConcurrentlyUpdatedLanes());
markRootFinished(root, lanes, remainingLanes, /* ... */);
// Schedule passive effects as EARLY as possible
if ((finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags) {
root.callbackNode = null;
root.callbackPriority = NoLane;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
if (/* subtree has BeforeMutation | Mutation flags */) {
// with DiscreteEventPriority, executionContext |= CommitContext
commitBeforeMutationEffects(root, finishedWork, lanes);
}
pendingEffectsStatus = PENDING_MUTATION_PHASE;
if (enableViewTransition && shouldStartViewTransition) {
pendingViewTransition = startViewTransition(
suspendedState, root.containerInfo, pendingTransitionTypes,
flushMutationEffects, flushLayoutEffects, flushAfterMutationEffects,
flushSpawnedWork, flushPassiveEffects, /* ... */);
} else {
flushMutationEffects();
flushLayoutEffects();
flushSpawnedWork();
}
}Things to notice:
markRootFinishedclears the committed lanes fromroot.pendingLanes. Whatever is infinishedWork.childLanes(updates skipped during this render) stays pending.- The passive-effects task is posted before any mutation happens. A comment links this to #16714: queueing it first means it's ahead of anything the commit itself schedules.6
- Every phase runs with
setCurrentUpdatePriority(DiscreteEventPriority)andReactSharedInternals.T = null. AsetStateinside a layout effect is therefore a sync update, not a transition, even if the render was a transition.
1. Before mutation
export function commitBeforeMutationEffects(root, firstChild, committedLanes) {
focusedInstanceHandle = prepareForCommit(root.containerInfo);
// ...
nextEffect = firstChild;
commitBeforeMutationEffects_begin(isViewTransitionEligible);
focusedInstanceHandle = null;
}prepareForCommit(DOM): savesgetSelectionInformation()(focused element and selection range) and disables the React event system (ReactBrowserEventEmitterSetEnabled(false)), so that DOM mutations that fire synchronous events (e.g.blurwhen a focused node is removed) don't re-enter React mid-commit.resetAfterCommit, at the end of mutation, restores selection and focus and re-enables events.8- Per fiber (
commitBeforeMutationEffectsOnFiber):9ClassComponentwithSnapshot→getSnapshotBeforeUpdate. This is the last moment the old DOM can be read (scroll position before a list grows).HostRootwithSnapshot→clearContainer(root.containerInfo). On the first client render,completeWorkset this flag (completeWork), so any server-less placeholder content in the container is removed (container.textContent = '').10ViewTransitionComponent→ snapshot/measure old state for an upcoming View Transition.- With
enableEffectEventMutationPhaseoff,useEffectEventimpls would be swapped here. In 19.3.1 the flag is on, so that swap happens at the start of the mutation phase instead.11
2. Mutation
function flushMutationEffects() {
if (pendingEffectsStatus !== PENDING_MUTATION_PHASE) return;
pendingEffectsStatus = NO_PENDING_EFFECTS;
// ...
if (subtreeMutationHasEffects || rootMutationHasEffect) {
// DiscreteEventPriority, CommitContext ...
commitMutationEffects(root, finishedWork, lanes);
resetAfterCommit(root.containerInfo);
}
// 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;
}The traversal
commitMutationEffectsOnFiber is a recursive walk. For each fiber it calls recursivelyTraverseMutationEffects first, which handles deletions on that parent and then recurses into children only if subtreeFlags & (MutationMask | Cloned). It then calls commitReconciliationEffects (Placement) and finally applies the fiber's own Update work.1314 The order within a parent is: deletions → children's effects → own placement → own update.
Deletions
commitDeletionEffects(root, returnFiber, deletedFiber) finds the nearest host parent, then walks the deleted subtree:1516
FunctionComponent: runs insertion-effect and layout-effect destroy functions (useInsertionEffect/useLayoutEffectcleanups) right away.ClassComponent: detaches refs, callscomponentWillUnmount.HostComponent:safelyDetachRef(call ref withnull, or the React 19 ref-cleanup function). It then recurses withhostParent = null, so descendants are not individually removed. Only the topmost host node getsremoveChild. This is the mirror image ofappendAllChildren: a subtree goes in with one insertion and comes out with one removal.- Passive (
useEffect) cleanups for deleted trees are not run here. They run in the passive phase (commitPassiveUnmountEffectsInsideOfDeletedTree_*).17
Placement: commitPlacement → insertOrAppendPlacementNode
function commitPlacement(finishedWork) {
// 1. find nearest host parent (HostComponent / HostRoot / HostPortal / HostSingleton)
let parentFiber = finishedWork.return;
while (parentFiber !== null) {
if (isHostParent(parentFiber)) { hostParentFiber = parentFiber; break; }
parentFiber = parentFiber.return;
}
switch (hostParentFiber.tag) {
case HostComponent: {
const parent = hostParentFiber.stateNode;
if (hostParentFiber.flags & ContentReset) { resetTextContent(parent); /* ... */ }
const before = getHostSibling(finishedWork);
insertOrAppendPlacementNode(finishedWork, before, parent, /* ... */);
break;
}
case HostRoot:
case HostPortal: {
const parent = hostParentFiber.stateNode.containerInfo;
const before = getHostSibling(finishedWork);
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent, /* ... */);
break;
}
}
}getHostSiblingfinds the DOM node toinsertBefore. It walks forward through siblings (descending through component fibers) looking for the first host node that is not itself being placed. If none exists, it returnsnulland the node is appended.19 This search is the cost of not keeping a parallel list of DOM nodes, and it's one reason a large keyed reorder is more expensive than it looks.insertOrAppendPlacementNode(IntoContainer): if the placed fiber is a host node, callinsertBefore/appendChildon itsstateNode. If it's a component, recurse into its children and insert each nearest host descendant.20
First render, concretely. The only fiber with Placement is App (page 07). Its host parent is the HostRoot, so insertOrAppendPlacementNodeIntoContainer(App, null, container) runs. App isn't a host node, so it recurses to <div>, which is, and calls appendChildToContainer(container, div) → container.appendChild(div).21 That one call is the moment the whole UI enters the document. (appendChildToContainer also sets onclick on non-root containers to work around an iOS Safari bubbling quirk, and appendChild/insertBefore will use the DOM's new moveBefore for moves behind enableMoveBefore, which is false in 19.3.1.22)
Updates
HostComponent with Update → commitHostUpdate(finishedWork, newProps, oldProps) → DOM commitUpdate → updateProperties (the actual prop diff, see page 08) + updateFiberProps.2324 HostText → textNode.nodeValue = newText.
FunctionComponent with Update:25
commitHookEffectListUnmount(HookInsertion | HookHasEffect, finishedWork, finishedWork.return);
commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork);
commitHookLayoutUnmountEffects(finishedWork, finishedWork.return, HookLayout | HookHasEffect);So useInsertionEffect runs (cleanup + setup) during mutation, before layout effects, which is what CSS-in-JS libraries need to inject <style> before anything measures. Layout effect cleanups also run here, during mutation. Setups run later, in the layout phase. All cleanups across the tree therefore run before any setup, so effect A's setup never observes effect B's stale state.
Why root.current = finishedWork sits exactly between mutation and layout
The source comment says it directly.12 componentWillUnmount and layout cleanups (mutation phase) should see the old tree as current, while componentDidMount/Update and layout setups (layout phase) should see the new one. Anything that reads root.current, such as findDOMNode-style lookups, event-target-to-fiber resolution, or a setState called from a layout effect that schedules from the current tree, gets a consistent answer. From this line on, the old current tree becomes the alternate, i.e. the scratch buffer for the next render (Fiber).
3. Layout
function flushLayoutEffects() {
if (pendingEffectsStatus !== PENDING_LAYOUT_PHASE) return;
// ...
if (subtreeHasLayoutEffects || rootHasLayoutEffect) {
commitLayoutEffects(finishedWork, root, lanes);
}
pendingEffectsStatus = PENDING_AFTER_MUTATION_PHASE;
}commitLayoutEffectOnFiber recurses children first (pruned by subtreeFlags & LayoutMask), then:2728
Because the walk is children-first, child layout effects and refs run before the parent's. A parent's useLayoutEffect can rely on childRef.current being set. The DOM is fully mutated and in the document at this point, so getBoundingClientRect() returns real layout. The browser has to compute layout synchronously (a forced reflow) to answer, but it has not painted.
4. Spawned work, and the sync passive flush
flushSpawnedWork (after the optional after-mutation phase used by View Transitions):31
requestPaint()tells Scheduler to setneedsPaint = true, soshouldYieldToHostreturnstrueat the next check and Scheduler hands the main thread back to the browser instead of running its next queued task.3233- Sets
pendingEffectsStatus = PENDING_PASSIVE_PHASEif there are passive effects. - Reports recoverable errors (
onRecoverableError) and calls the DevTools commit hook. - If the committed lanes include
SyncLane(discrete events like click or keydown, andflushSync), it callsflushPendingEffects()right now, so passive effects run synchronously at the end of the commit, before the browser paints:34JavaScript// 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. ... if (includesSyncLane(pendingEffectsLanes) && (disableLegacyMode || root.tag !== LegacyRoot)) { flushPendingEffects(); } ensureRootIsScheduled(root)for remaining lanes, nested-update loop detection (nestedUpdateCount, the source of "Maximum update depth exceeded").flushSyncWorkOnAllRoots(): if a layout effect (or a sync passive effect) calledsetState, that's a sync lane, and it is rendered and committed right here, in the same task.31 This is why "measure inuseLayoutEffect, thensetState" doesn't flicker: the second render also lands before paint.
5. Passive effects
For non-sync lanes (Default, Transition, …), passive effects run when the Scheduler task posted in commitRoot fires, at Normal priority:
function flushPassiveEffects() {
if (pendingEffectsStatus !== PENDING_PASSIVE_PHASE) return false;
const renderPriority = lanesToEventPriority(pendingEffectsLanes);
const priority = lowerEventPriority(DefaultEventPriority, renderPriority);
// setCurrentUpdatePriority(priority); T = null
return flushPassiveEffectsImpl();
}
function flushPassiveEffectsImpl() {
// ...
executionContext |= CommitContext;
commitPassiveUnmountEffects(root.current); // all useEffect cleanups first
commitPassiveMountEffects(root, root.current, lanes, transitions, /* ... */); // then setups
// ...
flushSyncWorkOnAllRoots();
}- All passive cleanups across the whole tree run before any passive setup, including cleanups for deleted subtrees.17
- Within each pass, the order is children-first (same as layout).
- A
setStateinsideuseEffectgets at mostDefaultEventPriority, so it's batched like a normal update, unless the commit was discrete, in which case the effects already ran synchronously in step 4.
Passive effects can also be flushed early by other paths: the next performWorkOnRootViaSchedulerTask / performSyncWorkOnRoot calls flushPendingEffects before rendering, and so does completeRoot before committing (Work Loop). The invariant is "effects of commit N run before render/commit N+1". It is not "effects run after paint".
When does the user actually see pixels?
The browser can only paint once the current JavaScript task (and its microtasks) has finished. The whole commit, from mutation through layout effects and any sync re-render they trigger, is one synchronous block of JS, so:
sequenceDiagram
autonumber
participant Ev as Task (event / Scheduler)
participant R as React render
participant C as commitRoot
participant B as Browser
participant S as Scheduler (next task)
Ev->>R: performWorkOnRoot (render phase, may yield if transition)
R->>C: completeRoot → commitRoot
C->>S: scheduleCallback(Normal, flushPassiveEffects)
C->>C: before mutation (getSnapshotBeforeUpdate, clearContainer on mount)
C->>C: mutation: deletions, insertions, commitUpdate, insertion effects, layout cleanups
C->>C: root.current = finishedWork
C->>C: layout: useLayoutEffect, componentDidMount/Update, refs
C->>C: requestPaint()
alt committed lanes include SyncLane (click, flushSync)
C->>C: flushPendingEffects() → useEffect runs NOW
end
C->>C: flushSyncWorkOnAllRoots() (setState from layout effects re-renders here)
Ev-->>B: task ends → style / layout / PAINT (user sees pixels)
B->>S: next macrotask
S->>S: flushPassiveEffects() → useEffect (non-sync lanes)This matches react.dev: "If your Effect wasn't caused by an interaction (like a click), React will generally let the browser paint the updated screen first before running your Effect", and "If your Effect is caused by an interaction (like a click), React may run your Effect before the browser paints the updated screen."37
View Transitions and gestures (19.3)
enableViewTransition is true in 19.3.1.38 When the committed lanes are View-Transition-eligible (transitions) and a <ViewTransition> is affected, commitRoot calls the DOM's startViewTransition(...), handing it flushMutationEffects, flushLayoutEffects, flushAfterMutationEffects, flushSpawnedWork and flushPassiveEffects as callbacks.239 The browser snapshots the old state, React mutates inside the update callback, and the browser animates between them. Mutation is deferred until the browser is ready, which is why the commit is a state machine. A flushSync during that window cancels the transition with a dev warning.5 Gesture transitions (startGestureTransition, enableGestureTransition = __EXPERIMENTAL__) add the PENDING_GESTURE_* states and are experimental-only.
First render vs update
With the React Compiler
The commit phase is unchanged. It consumes flags, and the compiler doesn't touch ReactFiberCommitWork.js. The effect is indirect:
- Fewer fibers re-render, so fewer
Updateflags on host fibers, fewerupdatePropertiesloops, and smallersubtreeFlagsfootprints, which means more subtrees pruned by theMutationMask/LayoutMaskchecks. - Effect dependency arrays become more stable. Objects and callbacks created in render are cached, so
useEffect(() => …, [options])stops re-firing becauseoptionswas a fresh literal. FewerHookHasEffectflags means fewer cleanup/setup pairs in the layout and passive phases. - Ordering and timing (layout before paint, passive after or sync for discrete) stay exactly the same.
See With vs Without Compiler and the full timelines in First Render Walkthrough and setState Walkthrough.
Summary
The commit phase applies the finished tree from completeWork synchronously and without yielding, in fixed sub-phases: before mutation, mutation, the root.current swap, layout, then passive effects. It is a state machine so View Transitions can defer mutation. The mental model: everything from mutation through layout effects, plus any sync re-render they trigger, is one block of JS that finishes before the browser can paint. useEffect usually runs after paint in a separate Scheduler task, but before paint when the commit came from a discrete update like a click, and even "after paint" is a heuristic left to the browser.
Key takeaways
completeRoot: flushes any leftover passive effects first, so effects of commit N run before render or commit N+1.commitRoot: posts the passive-effects task before any mutation and runs every phase atDiscreteEventPriority, so asetStatein a layout effect is sync.prepareForCommit: saves focus and selection and disables the event system; on first render the root'sSnapshottriggersclearContainer.- Mutation order: deletions, children's effects, own placement, own update; a deleted or inserted subtree costs one
removeChildor oneappendChild. useInsertionEffectand layout cleanups: both run during mutation, so all cleanups happen before any setup.root.current = finishedWork: sits between mutation and layout so unmount code sees the old tree and mount code sees the new one.- Layout phase: children-first, so child refs and
useLayoutEffectrun before the parent's, with real layout but no paint yet. flushSpawnedWork: callsrequestPaint(), flushes passive effects immediately forSyncLanecommits, and re-renders syncsetStatefrom layout effects in the same task.
← completeWork · Index · Hooks on Mount →
Footnotes
-
completeRoot(flush-previous-effects loop at L3530-L3532) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3511-L3723 (opens in a new tab) ↩ ↩2 -
commitRoot— packages/react-reconciler/src/ReactFiberWorkLoop.js#L3725-L3922 (opens in a new tab) ↩ ↩2 ↩3 -
pending-effects module state assigned in
completeRoot— packages/react-reconciler/src/ReactFiberWorkLoop.js#L3639-L3644 (opens in a new tab) ↩ -
pending-effects status constants — packages/react-reconciler/src/ReactFiberWorkLoop.js#L724-L731 (opens in a new tab) ↩
-
flushPendingEffects(View Transition cancel on flushSync) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4662-L4689 (opens in a new tab) ↩ ↩2 -
early passive-effect scheduling in
commitRoot— packages/react-reconciler/src/ReactFiberWorkLoop.js#L3771-L3827 (opens in a new tab) ↩ -
commitBeforeMutationEffects— packages/react-reconciler/src/ReactFiberCommitWork.js#L352-L371 (opens in a new tab) ↩ -
prepareForCommit/resetAfterCommit— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L424-L462 (opens in a new tab) ↩ -
commitBeforeMutationEffectsOnFiber— packages/react-reconciler/src/ReactFiberCommitWork.js#L483-L579 (opens in a new tab) ↩ -
clearContainer— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L3972-L3988 (opens in a new tab) ↩ -
enableEffectEventMutationPhase = true— packages/shared/ReactFeatureFlags.js#L139 (opens in a new tab) ↩ -
flushMutationEffectsand theroot.currentswap (L4047-L4051) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4009-L4053 (opens in a new tab) ↩ ↩2 -
commitMutationEffects/recursivelyTraverseMutationEffects— packages/react-reconciler/src/ReactFiberCommitWork.js#L2027-L2068 (opens in a new tab) ↩ -
commitMutationEffectsOnFiber— packages/react-reconciler/src/ReactFiberCommitWork.js#L2072-L2843 (opens in a new tab);commitReconciliationEffects— #L2845-L2864 (opens in a new tab) ↩ -
commitDeletionEffects— packages/react-reconciler/src/ReactFiberCommitWork.js#L1386-L1475 (opens in a new tab) ↩ -
commitDeletionEffectsOnFiberhost (L1569-L1630) and function component (L1706-L1729) cases — packages/react-reconciler/src/ReactFiberCommitWork.js#L1569-L1729 (opens in a new tab) ↩ -
recursivelyTraversePassiveUnmountEffects(deletions first) — packages/react-reconciler/src/ReactFiberCommitWork.js#L5071-L5116 (opens in a new tab);commitPassiveUnmountEffectsInsideOfDeletedTree_begin— #L5347 (opens in a new tab) ↩ ↩2 -
commitPlacement— packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L452-L543 (opens in a new tab) ↩ -
getHostSibling— packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L271-L328 (opens in a new tab) ↩ -
insertOrAppendPlacementNodeIntoContainer/insertOrAppendPlacementNode— packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L330-L450 (opens in a new tab) ↩ -
appendChildToContainer— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L1082-L1133 (opens in a new tab) ↩ -
supportsMoveBefore/appendChild— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L1026-L1042 (opens in a new tab);enableMoveBefore = false— packages/shared/ReactFeatureFlags.js#L194 (opens in a new tab) ↩ -
mutation HostComponent
Update→commitHostUpdate— packages/react-reconciler/src/ReactFiberCommitWork.js#L2302-L2314 (opens in a new tab) ↩ -
commitUpdate— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L999-L1012 (opens in a new tab) ↩ -
mutation FunctionComponent case (insertion effects, layout cleanups) — packages/react-reconciler/src/ReactFiberCommitWork.js#L2088-L2128 (opens in a new tab) ↩
-
flushLayoutEffects— packages/react-reconciler/src/ReactFiberWorkLoop.js#L4055-L4152 (opens in a new tab) ↩ -
commitLayoutEffectOnFiber(function L614-L626, class L627-L645, host L681-L705) — packages/react-reconciler/src/ReactFiberCommitWork.js#L600-L934 (opens in a new tab) ↩ -
commitLayoutEffects/recursivelyTraverseLayoutEffects— packages/react-reconciler/src/ReactFiberCommitWork.js#L3063-L3093 (opens in a new tab) ↩ -
commitHostMount— packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L70-L90 (opens in a new tab) ↩ -
commitAttachRef/safelyAttachRef— packages/react-reconciler/src/ReactFiberCommitEffects.js#L757-L840 (opens in a new tab) ↩ -
flushSpawnedWork(requestPaintL4189,flushSyncWorkOnAllRootsL4401) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4154-L4437 (opens in a new tab) ↩ ↩2 -
Scheduler
requestPaint— packages/scheduler/src/forks/Scheduler.js#L474-L479 (opens in a new tab) ↩ -
shouldYieldToHosthonoursneedsPaint— packages/scheduler/src/forks/Scheduler.js#L459-L472 (opens in a new tab) ↩ -
sync passive flush for discrete renders — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4316-L4329 (opens in a new tab) ↩
-
flushPassiveEffects— packages/react-reconciler/src/ReactFiberWorkLoop.js#L4691-L4726 (opens in a new tab) ↩ -
flushPassiveEffectsImpl(unmount then mount at L4788-L4795) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4728-L4875 (opens in a new tab) ↩ -
react.dev, "useEffect — Caveats" — https://react.dev/reference/react/useEffect#caveats (opens in a new tab) ↩
-
enableViewTransition = true,enableGestureTransition = __EXPERIMENTAL__— packages/shared/ReactFeatureFlags.js#L90-L96 (opens in a new tab) ↩ -
DOM
startViewTransition— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L2167 (opens in a new tab) ↩