What React does after the render phase finishes: the sub-phases of
commitRootand exactly when refs,useLayoutEffectanduseEffectrun. Read fromcommitRoot,flushMutationEffects,flushLayoutEffects,flushSpawnedWorkandflushPassiveEffectsin the React 19.2.5 build, plus react.dev’suseLayoutEffectanduseEffectpages. Part of React Internals, and a lower-level companion to React Lifecycle. My own notes and clarifications are marked with> 💬.
Interview Q&A
What happens in the commit phase?
React applies the finished work-in-progress tree to the screen in one
synchronous, uninterruptible pass: DOM mutations, then the tree swap
(root.current = finishedWork), then layout effects and ref attachment.
Passive effects (useEffect) are scheduled to run after it.
useLayoutEffect vs useEffect: when exactly does each run?
useLayoutEffect runs inside the commit, after the DOM is mutated but
before the browser paints, so it can measure layout and make changes the
user never sees in an intermediate state. useEffect runs after the
commit, normally after the paint, so it doesn’t delay what the user sees. In
our example, a tooltip measures itself in useLayoutEffect to avoid a flash
at the wrong position.
In what order do effects run between parent and child?
Children first, then the parent. React walks the tree and handles a fiber’s subtree before the fiber itself. For each kind of effect, all cleanups run before any setup.
Why can the commit phase not be interrupted?
Because it changes the DOM. Pausing halfway would show the user a half-updated UI. All the interruptible work happens in the render phase, before the first DOM write.
The problem: applying a finished tree atomically
After the render phase, React has a work-in-progress tree whose fibers carry
flags (Placement, Update, ChildDeletion, Ref, Passive,
LayoutMask…) and whose ancestors carry subtreeFlags. The commit has to:
- turn those flags into DOM calls,
- make the new tree official,
- give user code (refs, layout effects, passive effects) a chance to run at the right moments: some before paint because they must, and some after paint because they shouldn’t block it.
subtreeFlags makes each pass cheap. A traversal descends into a child
only if child.subtreeFlags & mask or child.flags & mask is non-zero, so
an update deep in a big tree only visits the path to it.
The sub-phases of commitRoot
In 19.2.5, commitRoot runs these in order (the helpers are split out so
view transitions can pause between them):
- DOM still shows the old UI
getSnapshotBeforeUpdate
- DOM writes (insert, update, remove)
- layout-effect cleanups
useInsertionEffect- detach old refs
root.current = finishedWork
- attach refs
useLayoutEffectsetupscomponentDidMount/Update
- browser draws pixels
- all
useEffectcleanups, then all setups - SyncLane: flushed before paint
commitBeforeMutationEffects(root, finishedWork, lanes) // 1. before mutation
flushMutationEffects() // 2. mutation (+ 3. swap)
flushLayoutEffects() // 4. layout
flushSpawnedWork() // 5. schedule / flush passive effects
1. Before mutation
This runs while the DOM still shows the old UI. It’s for reading
things that the mutation is about to change: class components’
getSnapshotBeforeUpdate (for example, a chat list reading scrollHeight
before new messages are inserted), and focus/blur bookkeeping.
2. Mutation
commitMutationEffectsOnFiber walks the flagged fibers and performs host
operations:
| Flag | DOM work |
|---|---|
ChildDeletion |
for each deleted child: detach refs, run layout-effect cleanups and (later) passive cleanups for the whole deleted subtree, then removeChild the top host node |
Placement |
commitPlacement finds the next stable host sibling (getHostSibling) and insertBefore it, or appendChild |
Update (host) |
commitUpdate diffs old and new props and sets only the changed attributes, styles and listeners. Text fibers call commitTextUpdate |
Update (function component) |
run useLayoutEffect cleanups of effects whose deps changed, and run useInsertionEffect cleanup + setup |
Ref |
detach the old ref (set to null, or call the React 19 ref cleanup) |
useInsertionEffectruns here, during mutation and before any layout effect, so CSS-in-JS libraries can inject<style>tags before anything measures layout. It’s the only effect that runs this early. It’s not meant for app code.
3. The swap
At the end of flushMutationEffects:
root.current = finishedWork
From this line on, the work-in-progress tree is the current tree (see double
buffering in React Fiber). It happens after mutation and before
layout, so class componentWillUnmount still sees the old tree, while
componentDidMount/useLayoutEffect see the new one.
4. Layout
commitLayoutEffectOnFiber walks the flagged fibers child before parent:
- attach refs (
ref.current = node, or call a callback ref) - run
useLayoutEffectsetups - class
componentDidMount/componentDidUpdate
All of this is synchronous JavaScript that runs before the browser gets a
chance to paint. If a layout effect calls setState, React processes that
update synchronously, before the paint, which is how “measure then
reposition” works without a visible flicker. The cost: a slow layout effect
directly delays the frame.
5. Passive effects (useEffect)
flushSpawnedWork checks whether any fiber had the Passive flag. If so,
React normally schedules flushPassiveEffects as a separate Scheduler
task (NormalPriority). The browser paints in between, so useEffect
runs after the user already sees the update.
There’s one exception in the source:
0 !== (pendingEffectsLanes & 3) && flushPendingEffects()
// lanes 1 and 2 = SyncHydrationLane | SyncLane
If the render was for a discrete update (a click, a keypress →
SyncLane), React flushes the passive effects synchronously at the end of
the commit. That’s the React 18 change react.dev mentions: effects caused
by a discrete input may run before the paint, so the result of an
interaction is consistent before the next input can arrive. If passive
effects are still pending when a new render starts, React always flushes them
first, so effects from render N always run before render N+1 commits.
flushPassiveEffects does two full traversals:
- All passive cleanups (
commitPassiveUnmountOnFiber): for deleted fibers and for effects whose deps changed. - All passive setups (
commitPassiveMountOnFiber).
Both traverse child before parent.
Effect ordering, step by step
function Parent() {
useLayoutEffect(() => { log('P layout'); return () => log('P layout cleanup') })
useEffect(() => { log('P effect'); return () => log('P effect cleanup') })
return <Child />
}
function Child() {
useLayoutEffect(() => { log('C layout'); return () => log('C layout cleanup') })
useEffect(() => { log('C effect'); return () => log('C effect cleanup') })
return <div ref={() => log('ref attached')} />
}
- commit phase
commitMutationEffects → appendChild(subtree)Mutation: the whole new DOM subtree (already built incompleteWork) is inserted with oneappendChild. - commit phase
commitLayoutEffects: attach refLayout phase walks child before parent. The deepest node first: the ref is attached. - commit phase
commitLayoutEffectOnFiber(Child) → useLayoutEffectThen Child’s layout effect… - commit phase
commitLayoutEffectOnFiber(Parent) → useLayoutEffect…then Parent’s. All before the browser paints. - browser
browser paintsNow the user sees the UI. - after paint
flushPassiveEffects → commitPassiveMountOnFiber(Child)Passive effects, again child first. - after paint
commitPassiveMountOnFiber(Parent)Parent’suseEffectruns last.
- commit phase
commitMutationEffects: layout cleanups (Child)Mutation phase: layout cleanups run, child first. - commit phase
commitMutationEffects: layout cleanups (Parent)Then the parent’s. - commit phase
root.current = finishedWork · layout: ChildAfter the tree swap, layout setups: child… - commit phase
layout: Parent…then parent. - browser
browser paintsPaint. - after paint
commitPassiveUnmountEffects (all cleanups)Passive effects run in two full passes. First every cleanup, child first… - after paint
commitPassiveUnmountEffects…parent cleanup… - after paint
commitPassiveMountEffects (all setups)…then every setup, child first… - after paint
commitPassiveMountEffects…and parent last. Rule: cleanups before setups, children before parents.
- commit phase
commitDeletionEffects(Parent)Deletion walks the removed subtree parent first: Parent’s layout cleanup… - commit phase
commitDeletionEffectsOnFiber(Child)…then it recurses into Child. - commit phase
safelyDetachRef(div) · removeChild(topNode)Refs are detached and the top DOM node is removed. - after paint
flushPassiveEffects (deleted subtree)In the next passive flush, effect cleanups for the deleted subtree, also parent first… - after paint
commitPassiveUnmountEffectsInsideOfDeletedTree…then the child. Deletions are the one place the order flips.
Mount:
| Order | Log | Phase |
|---|---|---|
| – | (DOM inserted: one appendChild of the whole subtree) |
mutation |
| 1 | ref attached |
layout (the div is deepest) |
| 2 | C layout |
layout |
| 3 | P layout |
layout |
| – | browser paints (for a non-discrete update like the initial render) | – |
| 4 | C effect |
passive |
| 5 | P effect |
passive |
Update (both re-render, no deps arrays, so every effect re-runs):
| Order | Log | Phase |
|---|---|---|
| 1 | C layout cleanup |
mutation |
| 2 | P layout cleanup |
mutation |
| 3 | C layout |
layout |
| 4 | P layout |
layout |
| 5 | C effect cleanup |
passive, cleanups pass |
| 6 | P effect cleanup |
passive, cleanups pass |
| 7 | C effect |
passive, setups pass |
| 8 | P effect |
passive, setups pass |
The callback ref didn’t log again on update only if it’s a stable function. An inline arrow is a new function every render, so React detaches it (
null) in mutation and attaches the new one in layout on every commit.
Unmount of Parent: in mutation, commitDeletionEffectsOnFiber walks
the deleted subtree parent first. It runs a fiber’s layout cleanups and
then recurses into its children, so the log is P layout cleanup → C layout cleanup. Refs are detached along the way, and then the top DOM node is
removed. Passive cleanups for the deleted subtree (P effect cleanup → C effect cleanup, also parent first for deletions) run in the next passive
flush.
This is the one place the order flips. For updates, cleanups go child → parent. For deletions, React tears down parent → child.
Where flushSync fits
flushSync(() => setState(x)) forces the update into SyncLane and
renders and commits it before flushSync returns. So the DOM is
updated synchronously inside your event handler, which is useful when you
need to read the new DOM right away (scrolling to a newly added item,
focusing it). It’s the imperative escape hatch for the same guarantee layout
effects give. See flushSync.
Rules and caveats
- Measure in
useLayoutEffect, fetch/subscribe inuseEffect. Layout effects block paint. Passive effects don’t (except after discrete events, where they’re flushed early, but still after the commit). - Cleanups always run before the next setup, and all cleanups in a pass run before any setup in that pass.
- Children’s effects run before their parent’s. A parent’s effect can rely on children’s refs being attached and their effects having run.
setStatein a layout effect re-renders synchronously before paint. This is correct for measure-and-adjust, and a performance trap if overused.- Strict Mode (dev only) mounts, unmounts and remounts every component once to check that cleanups mirror setups.
- Refs are
nullduring render, set in layout and cleared in mutation. Read them in effects or event handlers, not during render.
Sources
- React 19.2.5 source:
commitRoot,commitBeforeMutationEffects,flushMutationEffects(root.current = finishedWork),commitMutationEffectsOnFiber,commitPlacement,getHostSibling,flushLayoutEffects,flushSpawnedWork,flushPassiveEffects - react.dev:
useLayoutEffect - react.dev:
useEffect - React 18 upgrade guide: consistent useEffect timing
Primary source: github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberCommitWork.js