React Commit Phase Deep Dive: Sub-phases, Effects Order, and Practical Traps

React Commit Phase Deep Dive: Sub-phases, Effects Order, and Practical Traps

Mykola Nesterchuk - March 23, 2026

#react
#internals
#performance

Introduction

Most React developers have a rough mental model: React renders components, diffs the result against the previous tree, and updates the DOM. But the step between "diffing is done" and "user sees the update" is where most subtle bugs live.

That step is the commit phase.

Understanding it matters when you need to know exactly when a ref is populated, why useLayoutEffect prevents a flicker but useEffect doesn't, why child effects run before parent effects, and what useInsertionEffect is actually for.

This article walks through the commit phase in full - all three sub-phases, the execution order of every effect type, cleanup sequencing, and the practical bugs that come from misunderstanding the timing.

Where the Commit Phase Fits

React's work splits into two main phases:

  • Render phase - calls your component functions, builds a new fiber tree, diffs it against the current tree. Pure computation. Can be interrupted and replayed in concurrent mode. Produces a list of effects to apply.
  • Commit phase - takes that list and applies it. Synchronous and uninterruptible. This is the only place that touches the real DOM.
Trigger (state change, initial render)
     ↓
Render phase (interruptible in concurrent mode)
  - Calls component functions
  - Runs reconciliation
  - Builds work-in-progress fiber tree
  - Tags fibers with effect flags
     ↓
Commit phase (always synchronous, never interrupted)
  - Sub-phase 1: Before Mutation
  - Sub-phase 2: Mutation
  - Sub-phase 3: Layout
  - (async, after paint) Passive Effects

The render phase can be discarded and re-run - React does this in Strict Mode (deliberately) and in concurrent mode (when a higher-priority update arrives). The commit phase never restarts once it begins.

Sub-phase 1: Before Mutation

Before React touches the DOM, it runs commitBeforeMutationEffects. At this point the work-in-progress tree exists in memory but the current DOM has not changed yet.

The main thing that happens here is getSnapshotBeforeUpdate for class components - it lets you read layout information (scroll position, element dimensions) before any mutations occur. The return value gets passed to componentDidUpdate.

class ScrollableList extends React.Component { getSnapshotBeforeUpdate(prevProps, prevState) { // DOM hasn't changed yet - safe to read scroll position if (prevProps.items.length < this.props.items.length) { const list = this.listRef.current; return list.scrollHeight - list.scrollTop; } return null; } componentDidUpdate(prevProps, prevState, snapshot) { // snapshot = value returned from getSnapshotBeforeUpdate if (snapshot !== null) { const list = this.listRef.current; list.scrollTop = list.scrollHeight - snapshot; } } }

For function components, nothing user-visible happens in this sub-phase. React also schedules passive effects (useEffect) here - it sets a flag that passive effects need to run, but does not execute them yet.

Sub-phase 2: Mutation

This is where the DOM actually changes. React walks the fiber tree and applies three kinds of operations:

  • Placement - inserts new DOM nodes (Node.appendChild, Node.insertBefore)
  • Update - changes props on existing nodes (sets attributes, event listeners, styles)
  • Deletion - removes nodes and unmounts components

Deletion runs first within each subtree, so React always cleans up before inserting. This prevents briefly having both old and new versions of a node in the DOM.

Cleanup effects run here too. If a component is being deleted, its useLayoutEffect cleanup and useInsertionEffect cleanup fire during the mutation sub-phase. This is important: by the time componentWillUnmount or useLayoutEffect's cleanup runs on deletion, the component is already being removed from the DOM.

Also happening here: useInsertionEffect runs during the mutation sub-phase, before DOM nodes are inserted. Its purpose is narrow - injecting CSS-in-JS styles before any layout is measured. Libraries like styled-components and Emotion use this to ensure styles exist before useLayoutEffect reads layout:

// useInsertionEffect fires before DOM insertions // Only use this in CSS-in-JS libraries, not in application code useInsertionEffect(() => { const styleSheet = document.createElement('style'); styleSheet.textContent = `.my-class { color: red; }`; document.head.appendChild(styleSheet); return () => document.head.removeChild(styleSheet); }, []);

For application code, useInsertionEffect is almost never the right tool. Its timing is too early - you cannot reliably read DOM state from it.

At the end of the mutation sub-phase, React does something critical: it swaps the current tree. The work-in-progress tree becomes the new current tree (root.current = finishedWork). This happens between mutation and layout so that:

  • componentWillUnmount sees the old tree as current (needed for cleanup)
  • componentDidMount/componentDidUpdate see the new tree as current (needed for reading updated state)

Sub-phase 3: Layout

The DOM is now updated. The browser has not yet painted. React runs commitLayoutEffects.

This is where useLayoutEffect fires - synchronously, before the browser gets a chance to paint:

useLayoutEffect(() => { // DOM is updated, browser hasn't painted yet // Safe to read layout and make synchronous changes const rect = ref.current.getBoundingClientRect(); setTooltipPosition({ top: rect.bottom, left: rect.left }); }, []);

Because this runs synchronously and blocks painting, any state update you make inside useLayoutEffect forces an additional render before the browser shows anything. The user never sees the intermediate state - only the final result.

Class lifecycle methods componentDidMount and componentDidUpdate also fire here.

Refs are populated at the start of the layout sub-phase. This means ref.current is available inside useLayoutEffect but was null during rendering.

After the Commit: Passive Effects

useEffect is not part of the synchronous commit phase. After React finishes all three sub-phases and the browser has had a chance to paint, React runs passive effects asynchronously:

Commit phase completes
     ↓
Browser paints
     ↓
useEffect cleanups run (for changed deps)
     ↓
useEffect callbacks run

This is why useEffect cannot prevent layout flicker - the browser has already painted by the time it runs. But it is also why useEffect is the right choice for most side effects: it doesn't block the user from seeing the UI update.

The Exact Execution Order

Putting it all together - here is the order in which everything runs for a component update:

1. Before Mutation sub-phase
   - getSnapshotBeforeUpdate (class components)
   - Passive effects are scheduled (not yet run)

2. Mutation sub-phase
   - useInsertionEffect cleanups
   - useInsertionEffect callbacks
   - useLayoutEffect cleanups (for updated/deleted components)
   - DOM mutations (placement, update, deletion)
   - root.current = finishedWork (tree swap)

3. Layout sub-phase
   - Refs are attached
   - useLayoutEffect callbacks
   - componentDidMount / componentDidUpdate

4. Browser paints

5. Passive effects (async, after paint)
   - useEffect cleanups (for updated components)
   - useEffect callbacks

Child Effects Run Before Parent Effects

This is one of the most misunderstood details. Effects run bottom-up - children before parents:

function Parent() { useEffect(() => { console.log('Parent effect'); }, []); return <Child />; } function Child() { useEffect(() => { console.log('Child effect'); }, []); return <div />; } // Output: // Child effect // Parent effect

React traverses the fiber tree depth-first. When processing effects, it walks to the deepest child first and works its way back up. This mirrors the DOM - children are fully mounted before parents are notified.

The same applies to useLayoutEffect. This matters when a parent's layout effect depends on measurements that a child's layout effect might have modified.

Cleanup Runs Before the Next Effect

When a dependency changes, React runs the previous effect's cleanup before running the new effect. The order is:

useEffect(() => { console.log('effect', count); return () => console.log('cleanup', count); }, [count]); // count changes from 0 to 1: // cleanup 0 // effect 1

This applies to both useEffect and useLayoutEffect. Cleanup always precedes the new effect run. The cleanup sees the values from the render in which it was created - the count in the closure is the old count, not the new one.

For useLayoutEffect, cleanup runs synchronously during the mutation sub-phase before the next layout callback fires. For useEffect, cleanup runs asynchronously as part of the passive effects pass, right before the new callback.

Practical Traps

Trap 1: Reading layout in useEffect instead of useLayoutEffect

// Bug: tooltip flickers because browser paints before position is set useEffect(() => { const rect = anchorRef.current.getBoundingClientRect(); setPosition({ top: rect.bottom }); }, []); // Fix: runs before paint useLayoutEffect(() => { const rect = anchorRef.current.getBoundingClientRect(); setPosition({ top: rect.bottom }); }, []);

The rule: if the result of your effect would cause a visible flash if deferred, use useLayoutEffect. Otherwise use useEffect.

Trap 2: Expecting a ref to be populated during render

// Bug: ref.current is null during render function Component() { const ref = useRef(null); // ref.current is null here - render phase, not commit phase const width = ref.current?.offsetWidth; // undefined return <div ref={ref} />; } // Fix: read ref inside a layout effect function Component() { const ref = useRef(null); const [width, setWidth] = useState(0); useLayoutEffect(() => { // ref.current is populated here - layout sub-phase setWidth(ref.current.offsetWidth); }, []); return <div ref={ref}>{width}px wide</div>; }

Trap 3: setState inside useLayoutEffect causes double render

useLayoutEffect fires synchronously before paint. If you call setState inside it, React runs another render and another commit before the browser paints. Users never see the intermediate state - but you get extra work:

// This causes two renders before paint: // 1. Initial render with default position // 2. Re-render after useLayoutEffect sets position useLayoutEffect(() => { setPosition(measureElement(ref.current)); }, []);

For initial measurement this is often unavoidable. For updates, check whether you can calculate the value during render instead to avoid the extra pass.

Trap 4: Missing cleanup in useLayoutEffect

Because useLayoutEffect cleanup runs synchronously during the mutation sub-phase, a missing cleanup can leave stale listeners or DOM modifications that interfere with the next render:

// Bug: ResizeObserver leaks on every re-render useLayoutEffect(() => { const observer = new ResizeObserver(handleResize); observer.observe(ref.current); // Missing: return () => observer.disconnect(); }, []); // Fix: useLayoutEffect(() => { const observer = new ResizeObserver(handleResize); observer.observe(ref.current); return () => observer.disconnect(); }, []);

Trap 5: Assuming useEffect fires immediately after render

useEffect fires after the browser paints - which could be tens of milliseconds after the render completes. In concurrent mode, the gap can be even larger. Code that assumes useEffect runs synchronously with the render will fail:

// Bug: assumes the effect has already run by the time this check fires const hasFetched = useRef(false); useEffect(() => { hasFetched.current = true; fetchData(); }, []); // Immediately after render, hasFetched.current is still false console.log(hasFetched.current); // false - effect hasn't run yet

If you need to know whether an effect has run, track it with state rather than a ref read outside the effect.

When to Use Each Effect Hook

useInsertionEffect  - CSS-in-JS libraries only. Inject styles before layout
                      is measured. Do not use in application code.

useLayoutEffect     - Synchronous DOM measurement or mutation that would
                      cause a visible flicker if deferred. Tooltip positioning,
                      scroll restoration, DOM dimension reads that affect layout.

useEffect           - Everything else. Data fetching, subscriptions, logging,
                      analytics, timers. The right default for 95% of effects.

Start with useEffect. Only switch to useLayoutEffect when you see a visible flicker or need to read a DOM measurement that affects what the user first sees.

Summary

The commit phase is where React's declarative model meets the imperative DOM:

  • Before Mutation - reads the DOM before any changes, fires getSnapshotBeforeUpdate
  • Mutation - applies DOM insertions, updates, and deletions; fires useInsertionEffect and useLayoutEffect cleanups
  • Layout - populates refs, fires useLayoutEffect callbacks and componentDidMount/componentDidUpdate
  • Passive Effects - fires useEffect, asynchronously after the browser has painted

The key ordering rules: children before parents, cleanups before new effects, layout before paint, passive after paint.

Get the timing right, and you avoid flickers, stale refs, and effects that fire on values they should never have seen.

Table of Contents