React Deep-DivesApr 19, 2026·9 min read·

React's reconciler has three commit sub-phases

Every senior React dev can describe 'render and commit'. Almost nobody names the three ordered sub-phases of commit or points to where useLayoutEffect fires in ReactFiberWorkLoop.js. Here's the source-anchored story with two auto-animating canvases.

Play through a single React update from setState to paint:

JS (React)
renderRootSync()
beginWork · completeWork
commitRoot()
three sub-phases
DOM mutation
commitMutationEffects
refs attach/detach
commitLayoutEffects
useLayoutEffect (sync)
Browser
update the rendering
style · layout · paint
rendering opportunity
vsync / 16ms boundary
idle
step 1 / 6
No work pending. The fiber tree matches the DOM.
Auto-playing. Click any dot to jump.

Every React tutorial ships the same diagram: two boxes, render and commit. That's correct as far as it goes. Most React devs stop reading there.

But "commit" isn't one step. It's three ordered sub-phases plus a later async pass, all happening inside commitRoot in ReactFiberWorkLoop.js. Knowing which sub-phase your code lands in is the difference between "my ref is null" and "my ref is attached", between "I measured a stale layout" and "I measured the new one", between useEffect and useLayoutEffect.

This post reads the React source alongside the react.dev docs and pins down exactly what fires where.

tl;dr

React's commit phase runs three user-visible sub-phases in this order: before-mutation, mutation, layout — each implemented as a distinct function in ReactFiberWorkLoop.js. (On current React main a fourth — commitAfterMutationEffects — sits between mutation and layout when the enableViewTransition flag is on. Without View Transitions it's a no-op.) Then control yields to the browser for paint, and on the scheduler's next tick flushPassiveEffects runs your useEffect callbacks. useLayoutEffect fires in the layout sub-phase (synchronous, pre-paint). useEffect usually fires in passive effects after paint — but per react.dev, if the Effect is caused by a discrete interaction (a click, a keypress), React may run it before the browser paints. Your ref is set during the mutation sub-phase; by the time useLayoutEffect runs, refs point at the new DOM.

Render and commit: the primary source

react.dev states the boundary plainly:

"Rendering" is React calling your components... After rendering (calling) your components, React will modify the DOM.

Rendering must always be a pure calculation... It should not change any objects or variables that existed before rendering.

Two requirements fall out of that:

  1. Render is pure. React may run a render, throw it away, and re-run it — that happens on concurrent bail-outs, Suspense retries, and StrictMode's double-invoke. If your render mutates something, that mutation happens twice.
  2. The DOM is only touched in commit. There's a hard boundary between the two phases. The React source names them differently (renderRootSync vs. commitRoot) and they don't share execution context.

The react.dev page tells you this much. It does not tell you the commit function is itself three ordered sub-phases plus a deferred async pass. For that, you need the source.

The render phase in source

function renderRootSync(root: FiberRoot, lanes: Lanes, shouldYieldForPrerendering: boolean): RootExitStatus

Inside, React walks the work-in-progress fiber tree. beginWork descends into a fiber (calling your component). completeWork returns up the tree, computing side-effect flags. The result is a finished work-in-progress tree — a shadow of what the DOM should become, with a flag on each fiber saying what changed. Nothing is committed to the DOM yet. Any JS work you did in render lives only in React's data structures.

The concurrent variant (renderRootConcurrent, line 2757) is the same shape with yield points. Both flow into commitRoot once a tree is ready.

The commit phase: three ordered sub-phases

function commitRoot(root: FiberRoot, finishedWork: Fiber, lanes: Lanes, ...)

Inside commitRoot, React calls three functions in a fixed order. The imports at the top of the file make the sequence explicit:

ReactFiberWorkLoop.js (lines 244–248, abridged)js
1import {
2commitBeforeMutationEffects,
3...
4commitLayoutEffects,
5commitMutationEffects,
6} from './ReactFiberCommitWork';

In current React main, commitRoot invokes these via scheduling helpers (flushMutationEffects / flushLayoutEffects) rather than calling them inline — this lets View Transitions wrap the mutation/layout pair in a startViewTransition(...) callback when the feature flag is on. The call chronology stays the same: commitBeforeMutationEffects (around line 3852), then commitMutationEffects (around line 4009), then (gated on View Transitions) commitAfterMutationEffects around line 3983, then commitLayoutEffects (around line 4103). Only then does control yield.

Step through it:

commit phase — ordered sub-phases
commitRoot() → flushPassiveEffects
before-mutation · React
step 1 / 5
Snapshot the DOM state React is about to change.
getSnapshotBeforeUpdate
Sub-phases fire in this order, on every commit.

Each sub-phase has a job:

1. Before-mutation. getSnapshotBeforeUpdate fires. This is the last chance to read state from the old DOM — the DOM that still reflects the previous render — before React starts writing. Class components used this heavily. With hooks you rarely see it directly. It's still there for libraries that need a pre-mutation read.

2. Mutation. This is where the DOM actually changes. React walks the effect list and applies:

  • Inserts (appendChild), moves, removes.
  • Attribute and text diffs.
  • Ref detaches — old refs are cleared before the DOM node they pointed at is potentially removed.
  • useLayoutEffect cleanups from the previous render fire here (for fibers that are being updated or unmounted).

The subtle one: refs are half-updated during mutation. Old refs are null; new refs are not yet set.

3. Layout. The DOM is now correct. Refs that should point at new nodes get attached. useLayoutEffect setup functions fire synchronously, in tree order. componentDidMount and componentDidUpdate fire here too. This is the one sub-phase where you can safely read ref.current for the new DOM and mutate it without a visible flicker — the browser hasn't painted yet.

After these three sub-phases, commitRoot returns. Control goes back to the scheduler, which yields to the host — the browser.

What runs in the browser (not React)

The next thing that happens is not React. It's the browser's own per-frame algorithm:

For each navigable that has a rendering opportunity, queue a global task ... to update the rendering: ... run the resize steps ... run the scroll steps ... run the animation frame callbacks ... recalculate styles and update layout ...

This is the same 14-step algorithm covered in the layout-thrashing post. React has committed the DOM. The browser now takes its one-per-frame turn doing style recalc, layout, and paint. Until this finishes, the user hasn't seen the change.

Anything the browser paints is visible. Anything that runs before paint and mutates is invisible-and-correctable. That's what makes the layout sub-phase so powerful. It's the last moment React can fix up the DOM without the user ever seeing the in-between state.

Passive effects: useEffect is not part of commit

After the browser's paint, the scheduler gets a tick back. React scheduled flushPassiveEffects during commit; now it runs.

function flushPassiveEffects(): boolean

This is where useEffect cleanup and setup run. Critical details:

  • useEffect usually runs after paint. Per react.dev, if the Effect is caused by a discrete interaction (a click, a keypress), React may run it before the browser paints to avoid a visible inconsistent state. Either way, if you read layout in useEffect and then write to the DOM, on post-paint flushes the user sees a frame of the old DOM, then a frame of the new one. That's the classic "flash of wrong measurement" bug. The fix is to move the code to useLayoutEffect (layout sub-phase), which is guaranteed synchronous and pre-paint.
  • Cleanup fires before setup. For every fiber whose effect changed, the previous effect's cleanup runs first, then the new setup. This ordering is guaranteed by flushPassiveEffects walking the effect list twice.
  • It's async. If you need something to run "right after commit, synchronously", useEffect is the wrong hook. useLayoutEffect is synchronous in the commit phase.

A short diagnostic table for deciding which hook to use:

  • Measuring the DOM to compute a layout correction → useLayoutEffect (so the correction lands before paint).
  • Reading the DOM to fire an analytics event → useEffect (post-paint is fine; doesn't block).
  • Focusing an input after it mounts → useLayoutEffect if you need focus before the user sees the paint; useEffect otherwise.
  • Subscribing to an external store with a listener → useEffect (async is fine and safer).
  • Reading a ref that points at a child DOM node → both work, but useLayoutEffect is safer if you need to act on the measurement before paint.

What this buys you

Most React bugs you can't explain cleanly up against this sequence and resolve:

  • "My ref is null in useEffect." It's not — you're probably in a conditional where the child unmounted before commit. But checking the sub-phase is the right mental move: ref attach happens in the layout sub-phase, and your effect reads it after that. If it's null, the node doesn't exist.
  • "useLayoutEffect fires twice." In StrictMode in dev, React invokes the render phase twice to catch impure renders. Commit fires once. But if you log inside both, you see effects fire, unmount, and re-fire — that's StrictMode's double-invoke of commit-phase work to surface missed cleanups, not the normal case.
  • "My scroll-to-top effect causes a flash." You're in useEffect; the paint happened first, and the user saw the scroll position before you reset it. Move to useLayoutEffect.
  • "Two effects fire in the wrong order." Passive effects are flushed in tree order; layout effects also in tree order. If you have an effect that must run after a parent's effect, check whether the dependency is encoded in tree position or just in your head.

None of these have satisfying answers in the "render and commit" two-box model. All of them compress to one sentence in the three-sub-phases-plus-passive-effects model.

Primary sources