⚛ The Course  /  Module 2 · One Pass of Work · 2.2
Module 2 · One Pass of Work

2.2 Two phases: render vs commit

You just learned how React walks the fiber tree to figure out what the UI should look like. But when does that work actually touch the screen? The answer is: not while React is still computing. React splits every update into two phases with completely different rules — a render phase that thinks in private, and a commit phase that acts in public. This lesson is about that split, and why it is the reason React can stay responsive while it works.

Two phases, two sets of rules

An update flows through React in a fixed order: first render, then commit. They are not two names for the same thing — they obey opposite rules.

The render phase is pure computation. It builds the new fiber tree in memory and touches nothing the user can see. Because it changes nothing real, React is free to pause it, hand control back to the browser, and resume later — or throw the work away entirely.

The commit phase is the opposite. This is where React applies its computed changes to the real DOM. It runs straight through, synchronously, with no pause points, because the user must never catch the page in a half-updated state.

┌─────────────────────────────────────────────────────────────┐
│ RENDER PHASE (Interruptible)                                │
├─────────────────────────────────────────────────────────────┤
│ • Pure computation                                          │
│ • Builds virtual DOM (fiber tree)                           │
│ • Can be paused via shouldYield()                           │
│ • Can be interrupted by higher priority work                │
│ • Can be completely abandoned                               │
│ • NO side effects                                           │
│ • NO DOM changes                                            │
│ • Work stays in MEMORY only                                 │
└─────────────────────────────────────────────────────────────┘
                        ↓
                    (when complete)
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ COMMIT PHASE (Uninterruptible — Synchronous)                │
├─────────────────────────────────────────────────────────────┤
│ • Applies all changes to real DOM                           │
│ • CANNOT be interrupted                                     │
│ • CANNOT be paused                                          │
│ • ALL-OR-NOTHING operation                                  │
│ • Runs synchronously                                        │
│ • Executes useLayoutEffect                                  │
│ • Browser paint happens AFTER this completes                │
└─────────────────────────────────────────────────────────────┘

Side by side, the contrast is the whole lesson in one table:

AspectRender PhaseCommit Phase
Can be interrupted?YESNO
Can be paused?YES (shouldYield)NO
Can be abandoned?YESNO
DOM changes?NO (memory only)YES (all at once)
Side effects?NOYES (useLayoutEffect)
User visible?NOYES
DurationVariable (can be long)Fast — synchronous
Partial work visible?NEVERALL-OR-NOTHING

The render phase keeps everything in memory

Here is the most common wrong picture of how this works: people imagine that when React pauses after rendering some items, it pushes those items to the screen first. It does not. Pausing the render phase shows the user nothing new.

React keeps all rendered work in the fiber tree, in memory, and commits only when the entire render is complete. A pause is just a pause — the half-built tree waits silently until React resumes.

Why insist on this all-or-nothing rule? Three reasons. Consistency: a partial commit would show a state that never truly existed in your app. Performance: every commit makes the browser recompute layout and repaint, so batching all the work into one commit is cheaper than many small ones. Correctness: effects need the whole tree, refs need every DOM node present, and event handlers expect a complete structure.

Watch it play out with a long list. React renders for a few milliseconds, yields so the browser stays responsive, and resumes — but the DOM does not change until the very end:

T=0ms:    Start rendering item 0
          ├─ Build fiber for item 0   (in memory)
          ├─ Build fiber for item 1   (in memory)
          └─ Build fiber for item 100 (in memory)

T=5ms:    shouldYield() = true
          ├─ 100 fibers built (in memory only)
          └─ Yield to browser

T=5–15ms: Browser is free
          ├─ ✓ User can click, type, scroll
          ├─ ✓ Browser can repaint existing DOM
          ├─ ✗ Items 0–100 NOT in DOM yet
          └─ ✗ Users see OLD state

T=15ms:   Resume — continue building fibers 101–9999 (in memory)

T=50ms:   Rendering complete — ALL 10,000 fibers built in memory
          └─ Enter COMMIT phase

T=50ms:   COMMIT PHASE (uninterruptible)
          ├─ Apply ALL 10,000 items to real DOM
          └─ Browser reflows / repaints

T=60ms:   Users NOW see all 10,000 items

The real code

The proof is in how concurrent rendering returns. If there is still work left (workInProgress !== null), it returns a "still in progress" status and commits nothing. Commit only happens once the status says rendering is fully done:

function renderRootConcurrent(root, lanes) {
  workLoopConcurrent();  // can be interrupted here

  if (workInProgress !== null) {
    return RootInProgress;   // NO commit — just return
  } else {
    return RootCompleted;    // ready to commit
  }
}

// commitRoot is called ONLY when RootCompleted:
if (exitStatus !== RootInProgress) {
  finishConcurrentRender(root, exitStatus, finishedWork, lanes);
  // calls commitRoot() — only when ALL rendering is done
}

What actually makes commit uninterruptible?

It is tempting to think the commit "has higher priority" so nothing can stop it. That is not the reason. The real reason is simpler and more mechanical: the commit is plain synchronous JavaScript with no pause points anywhere.

The render phase can pause only because its loop chooses to. After each unit of work, workLoopConcurrent explicitly asks shouldYield() whether it is time to hand control back. The commit code never asks that question — so once it starts, it runs to the end like any ordinary function call.

The real code

// RENDER PHASE (interruptible) — the loop CHOOSES to yield:
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

// COMMIT PHASE (uninterruptible) — no shouldYield() anywhere.
// React 18 removed the per-fiber fiber.nextEffect linked list.
// commitMutationEffects does a RECURSIVE subtree walk, pruned by subtreeFlags:
function commitMutationEffects(root, finishedWork, committedLanes) {
  commitMutationEffectsOnFiber(finishedWork, root);  // recurse from the top
}

function recursivelyTraverseMutationEffects(root, parentFiber) {
  // 1. Fire this fiber's deletions first.
  for (const child of (parentFiber.deletions ?? [])) {
    commitDeletionEffects(root, parentFiber, child);
  }
  // 2. Recurse into children ONLY if the subtree has mutation work.
  if (parentFiber.subtreeFlags & MutationMask) {
    let child = parentFiber.child;
    while (child !== null) {
      commitMutationEffectsOnFiber(child, root);  // depth-first
      child = child.sibling;
    }
  }
}

Inside the commit: three sub-phases

"Commit" is not a single step. It is three small phases that run back to back, always in the same order:

  • Before mutation — read the DOM before it changes (for example getSnapshotBeforeUpdate). The user still sees the old DOM.
  • Mutation — actually change the DOM: insert nodes, update properties, remove nodes. This is the only place real DOM writes happen.
  • Layout — run code that needs the new DOM in place, such as useLayoutEffect and ref assignment.

And one detail that fits right between them: the atomic swap. After mutation finishes but before layout runs, React flips one pointer — root.current = finishedWork — so its reconciler now treats the new tree as the live one.

The real code

function commitRootImpl(root, recoverableErrors, transitions) {
  const finishedWork = root.finishedWork;
  const lanes = root.finishedLanes;

  // Phase 1: Before Mutation
  commitBeforeMutationEffects(root, finishedWork);

  // Phase 2: Mutation — ALL DOM changes happen here
  commitMutationEffects(root, finishedWork, lanes);

  // ====== THE ATOMIC SWAP ======
  root.current = finishedWork;  // ONE POINTER ASSIGNMENT
  // React's reconciler now sees new tree.
  // Users still see old pixels until browser paints.

  // Phase 3: Layout — useLayoutEffect runs
  commitLayoutEffects(finishedWork, root, lanes);

  // Commit complete. Browser paints after JS returns to event loop.
}

Why the swap sits exactly between mutation and layout

The position is not arbitrary. It is dictated by what lifecycle code is allowed to see. Unmounting code (like componentWillUnmount and effect cleanups) runs during mutation and must still see the old tree as current — it is tearing down what was on screen. Mounting and updating code (like componentDidMount, useLayoutEffect, and refs) runs during layout and must see the new tree. So the swap has to land in the gap between them.

React's own source comment at that line says it plainly:

The real code

// commitRootImpl, in order:
commitMutationEffects(root, finishedWork, lanes);  // 1. UNMOUNTS run here
                                                   //    componentWillUnmount, layout-effect cleanup
                                                   //    must still see the OLD tree as current
root.current = finishedWork;                       // 2. atomic swap — BETWEEN the two phases
commitLayoutEffects(finishedWork, root, lanes);    // 3. MOUNTS run here
                                                   //    componentDidMount/Update, useLayoutEffect, refs
                                                   //    must see the NEW tree as current

The full picture, with timing

Here is one whole commit, start to finish. Notice that useEffect is only scheduled at the very top — its body does not run until after the browser paints:

T=10ms:   commitRootImpl() — plain synchronous JS begins
          │
          ├─ SCHEDULE passive effects (useEffect) — BEFORE any sub-phase:
          │    if ((finishedWork.subtreeFlags | finishedWork.flags) & PassiveMask) {
          │      scheduleCallback(NormalPriority, flushPassiveEffects);
          │    }
          │    (Only QUEUES the callback — useEffect bodies run after paint)
          │
          ├─ Phase 1: commitBeforeMutationEffects()
          │    getSnapshotBeforeUpdate, blur handling
          │    User STILL sees old DOM
          │
          ├─ Phase 2: commitMutationEffects()
          │    Traverse finishedWork tree
          │    Placement flag → insert DOM node
          │    Update flag    → update DOM properties
          │    ChildDeletion  → remove DOM node
          │    DOM now reflects new state
          │    Browser hasn't painted yet — user STILL sees old screen
          │
          ├─ root.current = finishedWork   ← ATOMIC SWAP
          │    React's reconciler sees new tree
          │
          ├─ Phase 3: commitLayoutEffects()
          │    ALL useLayoutEffect callbacks run
          │    Refs are assigned (see §5.3 below)
          │    Commit complete — 2ms total
          │
T=13ms:   Browser reflow + repaint
          └─ User NOW sees new UI

T=14ms:   flushPassiveEffects() macrotask fires (after paint)
          ├─ useEffect CLEANUPS from previous render
          └─ useEffect callbacks for this render

The diff is computed in render, applied in commit

One more boundary worth pinning down: where the actual DOM write lives. During render, completeWork figures out what changed and stashes that as an update payload on the fiber — but it touches no real DOM. The write itself happens later, in the mutation sub-phase of commit.

RENDER PHASE (completeWork):
├─ diffProperties() → computes ['className', 'new-value']
├─ workInProgress.updateQueue = updatePayload   ← STORED
├─ markUpdate() → sets Update flag
└─ DOM UNCHANGED

COMMIT PHASE (commitMutationEffects):
├─ updatePayload = finishedWork.updateQueue
├─ commitUpdate() → updateProperties()
├─ element.className = 'new-value'              ← REAL DOM UPDATE
└─ DOM NOW CHANGED
Show the full source — compute (render) vs apply (commit)
// ====== WHERE THE DIFF IS COMPUTED (Render Phase — completeWork) ======
function updateHostComponent(current, workInProgress, type, newProps) {
  const oldProps = current.memoizedProps;
  if (oldProps === newProps) return;  // early bailout

  const instance = workInProgress.stateNode;  // the actual DOM element

  // computeS the diff — does NOT apply it:
  const updatePayload = diffProperties(instance, type, oldProps, newProps);
  //                    returns: ['className', 'new-value', 'style', {...}]

  workInProgress.updateQueue = updatePayload;  // STORED, not applied
  if (updatePayload) markUpdate(workInProgress);
}
// NO DOM CHANGES YET — just computed and stored.


// ====== WHERE THE DIFF IS APPLIED (Commit Phase) ======
case HostComponent: {
  var instance = finishedWork.stateNode;
  var updatePayload = finishedWork.updateQueue;  // the stored diff
  finishedWork.updateQueue = null;

  if (updatePayload !== null) {
    // NOW THE DOM IS ACTUALLY UPDATED:
    commitUpdate(instance, updatePayload, type, oldProps, newProps, finishedWork);
    // → updateProperties() → element.className = 'new-value'
  }
}