Skip to content

Render Phase vs Commit Phase

advanced11 min read

Two Phases, One Render

When you call setState, React doesn't immediately update the DOM. It runs through two distinct phases, and honestly, understanding the boundary between them is the key to understanding almost everything about how React works.

function Counter() {
  const [count, setCount] = useState(0);
  const ref = useRef(null);

  // RENDER PHASE: This runs during the render phase
  // - Called by React to compute the UI
  // - Must be pure — no side effects
  // - Can be called multiple times, interrupted, or discarded
  console.log('Render phase:', count);

  useEffect(() => {
    // COMMIT PHASE (post-commit): This runs after DOM mutations
    // - DOM is updated, refs are attached
    // - Safe to read DOM measurements, start subscriptions
    console.log('Effect:', ref.current.textContent);
  });

  useLayoutEffect(() => {
    // COMMIT PHASE (layout): This runs synchronously after DOM mutations
    // - Before browser paint
    // - Safe to read/write DOM synchronously
    console.log('Layout effect:', ref.current.offsetHeight);
  });

  return <p ref={ref}>Count: {count}</p>;
}

The Mental Model

Mental Model

Think of rendering as drafting a blueprint and committing as construction day.

During the draft phase (render), the architect draws plans on paper. They can erase, redraw, throw away the entire draft and start over. Nothing physical changes. The architect might draft three versions before settling on one. No walls are moved, no pipes are connected.

On construction day (commit), the builder takes the final approved blueprint and executes it in one continuous session. Walls go up, plumbing connects, electricity turns on — all in order, all at once, no interruptions. You can't stop mid-wall and come back tomorrow.

After construction is complete, the inspector walks through (effects). They check measurements (useLayoutEffect — before anyone moves in) and schedule future maintenance (useEffect — after the building is in use).

Render Phase: Pure Computation

The render phase is everything that happens inside your component function (and beginWork/completeWork in the fiber tree):

function ProductCard({ product }) {
  // ALL of this is render phase
  const [expanded, setExpanded] = useState(false);
  const price = formatCurrency(product.price);     // Pure computation
  const inStock = product.inventory > 0;            // Pure computation
  const classes = `card ${expanded ? 'expanded' : ''}`; // Pure computation

  // DO NOT DO THIS — side effects in render phase
  // document.title = product.name;  // DOM mutation in render!
  // fetch('/api/view/' + product.id); // Network request in render!
  // analytics.track('view', product);  // Side effect in render!

  return (
    <div className={classes}>
      <h2>{product.name}</h2>
      <span>{price}</span>
      {inStock && <button onClick={() => setExpanded(true)}>Details</button>}
    </div>
  );
}

Render phase rules:

  • Must be pure — same inputs, same output, no observable side effects
  • Can be interrupted — React may pause and resume later (concurrent features)
  • Can be discarded — if a higher-priority update arrives, the in-progress render is thrown away
  • Can run multiple times — React may call your function twice in StrictMode to catch impurities
  • Produces fiber nodes with effect flags — a description of what needs to change, not the changes themselves
Execution Trace
Trigger:
setState(newValue)
React schedules a render on the appropriate lane
Render:
beginWork → component()
Call component function. Run hooks. Diff children. Set flags
Yield?:
shouldYield() check
For concurrent renders: pause here if 5ms elapsed
Continue:
Next fiber: beginWork → component()
Process each fiber. Pure computation only
Complete:
completeWork bottom-up
Create DOM nodes (not inserted yet). Bubble effect flags
Ready:
workInProgress tree complete
All fibers processed. Effect list built. No DOM touched yet

Commit Phase: Synchronous DOM Mutations

Once the render phase completes, React enters the commit phase. This is synchronous — it cannot be interrupted. The commit phase has three sub-phases:

1. Before Mutation

// React runs getSnapshotBeforeUpdate for class components
// Reads the DOM state BEFORE mutations (e.g., scroll position)
function commitBeforeMutationEffects() {
  // Read scroll position before DOM changes
  // This value is passed to componentDidUpdate
}

2. Mutation

// The actual DOM changes happen here
function commitMutationEffects(root, finishedWork) {
  // Walk the fiber tree, process flags:
  // - Placement: insertBefore / appendChild
  // - Update: update DOM attributes, text content
  // - Deletion: removeChild, run cleanup effects
  // - Ref: detach old refs (set to null)
}

3. Layout

// After DOM mutations, before browser paint
function commitLayoutEffects(root, finishedWork) {
  // - Attach new refs (ref.current = DOM node)
  // - Run useLayoutEffect callbacks
  // - Run componentDidMount / componentDidUpdate
  // These can read the updated DOM synchronously
}

4. Passive Effects (after paint)

// Scheduled asynchronously after browser paint
function flushPassiveEffects() {
  // Run useEffect cleanup functions (from previous render)
  // Run useEffect callbacks (from this render)
  // These run asynchronously — browser has already painted
}
Execution Trace
Before:
commitBeforeMutationEffects
Read DOM state before changes (scroll position, etc.)
Mutate:
commitMutationEffects
INSERT, UPDATE, DELETE DOM nodes. Detach old refs
Swap:
root.current = finishedWork
The workInProgress tree becomes the current tree
Layout:
commitLayoutEffects
Attach refs. Run useLayoutEffect. Can read DOM synchronously
Paint:
Browser paints
The user sees the update on screen
Passive:
flushPassiveEffects
Run useEffect cleanup, then useEffect callbacks. Async

The Effect Timeline

This is where most people's mental model breaks down. Let's walk through exactly when effects run relative to DOM mutations and paint:

function Timeline() {
  const [count, setCount] = useState(0);
  const ref = useRef(null);

  // 1. Render phase: component function runs
  console.log('A: render', count);

  useLayoutEffect(() => {
    // 3. After DOM mutation, before paint
    console.log('C: layoutEffect', ref.current.textContent);
    return () => {
      // Cleanup runs before next layoutEffect, synchronously
      console.log('C-cleanup: layoutEffect cleanup');
    };
  });

  useEffect(() => {
    // 5. After paint (asynchronous)
    console.log('E: effect', ref.current.textContent);
    return () => {
      // Cleanup runs before next effect execution
      console.log('E-cleanup: effect cleanup');
    };
  });

  // 2. Render phase: return JSX
  return <p ref={ref}>Count: {count}</p>;
}

// On initial mount, console shows:
// A: render 0
// C: layoutEffect "Count: 0"
// E: effect "Count: 0"

// On setState(1):
// A: render 1
// C-cleanup: layoutEffect cleanup
// C: layoutEffect "Count: 1"
// E-cleanup: effect cleanup
// E: effect "Count: 1"
Common Trap

useLayoutEffect runs before the browser paints but after DOM mutations. This means you can read updated DOM measurements (like offsetHeight) and make synchronous DOM changes that the user will see in the same frame. But it blocks painting — if your layout effect takes 100ms, the user sees a 100ms freeze. Use useEffect unless you specifically need pre-paint DOM access.

Production Scenario: The Flickering Tooltip

This is a classic bug that every team hits at least once. A team builds a tooltip that positions itself based on the trigger element's dimensions:

// BUG: Tooltip flickers on every show
function Tooltip({ triggerRef, content }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const tooltipRef = useRef(null);

  useEffect(() => {
    // This runs AFTER paint — browser already rendered tooltip at (0,0)
    // Then this repositions it — causing a visible flicker
    const rect = triggerRef.current.getBoundingClientRect();
    const tooltip = tooltipRef.current;
    setPosition({
      top: rect.bottom + 8,
      left: rect.left + rect.width / 2 - tooltip.offsetWidth / 2,
    });
  });

  return (
    <div ref={tooltipRef} style={{ position: 'fixed', top: position.top, left: position.left }}>
      {content}
    </div>
  );
}

The tooltip appears at position (0, 0) for one frame, then jumps to the correct position. The fix: use useLayoutEffect to measure and position before the browser paints:

function Tooltip({ triggerRef, content }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const tooltipRef = useRef(null);

  useLayoutEffect(() => {
    // Runs BEFORE paint — user never sees the wrong position
    const rect = triggerRef.current.getBoundingClientRect();
    const tooltip = tooltipRef.current;
    setPosition({
      top: rect.bottom + 8,
      left: rect.left + rect.width / 2 - tooltip.offsetWidth / 2,
    });
  });

  return (
    <div ref={tooltipRef} style={{ position: 'fixed', top: position.top, left: position.left }}>
      {content}
    </div>
  );
}

Why Render Must Be Pure

This is the part that bites people when they upgrade to concurrent features. React can call your render function and then throw away the result. It can call it twice in development (StrictMode). It can pause mid-render and resume later. If your render has side effects, you're in for a world of pain:

function ImpureComponent({ userId }) {
  // These all break under concurrent React:
  analytics.track('view', userId);  // Tracked twice in StrictMode, tracked for discarded renders
  window.scrollTo(0, 0);           // Scroll jumps during interrupted renders
  cache.set(userId, Date.now());   // Cache polluted with data from discarded renders

  return <div>{userId}</div>;
}

Side effects in render become bugs that only appear in concurrent mode, making them extremely hard to reproduce and debug. StrictMode double-invocation exists specifically to catch these.

Common Mistakes

Common Mistakes
  • Wrong: Performing DOM measurements in the render phase Right: Use useLayoutEffect to read DOM dimensions after mutations, before paint

  • Wrong: Using useLayoutEffect for subscriptions and data fetching Right: Use useEffect for async operations. useLayoutEffect is only for synchronous DOM reads/writes before paint

  • Wrong: Mutating refs during render (ref.current = value outside effects) Right: Write to refs in useEffect, useLayoutEffect, or event handlers

  • Wrong: Assuming useEffect runs immediately after render Right: useEffect runs asynchronously after the browser has painted. There's a visible gap

Challenge

Order the execution

Show Answer

Initial mount output: 1: App render 6: Child render 7: Child layoutEffect 2: App layoutEffect 9: Child effect 4: App effect

Why this order:

  • Render phase goes top-down: App renders, then Child renders
  • Layout effects fire bottom-up (child before parent) — because completeWork walks up the tree
  • Passive effects (useEffect) also fire bottom-up: child before parent

On re-render (triggered from App): 1: App render 6: Child render 8: Child layoutEffect cleanup 3: App layoutEffect cleanup 7: Child layoutEffect 2: App layoutEffect 10: Child effect cleanup 5: App effect cleanup 9: Child effect 4: App effect

Cleanup runs bottom-up before new effects run bottom-up. All cleanups fire before any new effects of the same type.

Quiz

Quiz
Why can React interrupt and restart the render phase but not the commit phase?

Key Rules

Key Rules
  1. 1Render phase is pure computation: call components, diff children, set effect flags. No DOM, no side effects, can be interrupted.
  2. 2Commit phase is synchronous and cannot be interrupted: DOM mutations, ref assignments, layout effects, then passive effects.
  3. 3useLayoutEffect runs after DOM mutations but before browser paint. Use it for DOM measurements and synchronous layout corrections.
  4. 4useEffect runs asynchronously after browser paint. Use it for subscriptions, data fetching, and non-urgent side effects.
  5. 5Effects fire bottom-up: child effects run before parent effects. Cleanups also fire bottom-up, all cleanups before new effects.
  6. 6Render must be pure because React can call it multiple times (StrictMode), interrupt it, or discard it entirely.