How the DOM Gets Updated: The Commit Phase
What you'll learn
- How React marks what needs to change during render, before touching any DOM
- How updates are applied with surgical, attribute-level precision
- Why new subtrees are built in memory first and attached in one shot
- The exact order of the commit: measure, mutate, attach refs, paint, effects
Every chapter so far has been careful to say "the DOM isn't touched yet." Render computes; diffing flags; the draft swaps. Sooner or later, though, real pixels must change. That moment is the commit phase, and it's engineered around one obsession: the DOM is slow, so touch it as little as possible, as few times as possible, in the right order.
Jargon: "commit phase". The short, uninterruptible phase after a completed render where React applies every computed change to the real DOM, attaches refs, and schedules effects. Unlike render, commit cannot be paused or thrown away, it owns the screen.
Flags: the shopping list written during render
During the render-and-diff walk, whenever React decides a fiber needs real work, it doesn't do the work, it sticks a label on the fiber:
Jargon: "effect flag". A small marker placed on a work-in-progress fiber during render, recording what kind of DOM work it needs: insert this node, update these props, delete this subtree, move this node. Commit later walks the tree and executes only the flagged fibers, no re-deciding, no searching.
Pseudocode model, not real source:
// during render + diff:if (typeChanged) { mark(parent, DELETION); mark(newFiber, PLACEMENT); }else if (propsChanged) { mark(fiber, UPDATE, changedPropList); }if (nodeMustMove) { mark(fiber, PLACEMENT); } // placement = insert OR move// during commit:for (const fiber of flaggedFibers) {if (fiber.flags.has(PLACEMENT)) insertOrMove(fiber);if (fiber.flags.has(UPDATE)) applyPropChanges(fiber);if (fiber.flags.has(DELETION)) removeSubtree(fiber);}
Commit is therefore mechanical: checklist execution, not decision-making. All the thinking happened in the interruptible phase; all the doing happens in the uninterruptible one.
Updates: attribute-level surgery
An UPDATE flag doesn't mean "rewrite the node", it carries the exact prop diff. Three micro-examples.
1. A class change is one write.
import { useState } from 'react';
export default function App() {
const [active, setActive] = useState(false);
return (
<button
className={active ? 'btn active' : 'btn'}
onClick={() => setActive(!active)}
>
Toggle
</button>
);
}
What happens at commit: the prop diff contains only className. React performs the conceptual equivalent of button.className = 'btn active', one property write. The text child, the listener wiring, the node itself: untouched.
2. Style is diffed key by key.
import { useState } from 'react';
export default function App() {
const [offset, setOffset] = useState(0);
return (
<div>
<p style={{
color: 'navy',
fontSize: 16,
transform: 'translateX(' + offset + 'px)',
}}>
Sliding text
</p>
<button onClick={() => setOffset(offset + 10)}>Move</button>
</div>
);
}
What happens at commit: React compares the old style object to the new one key by key. color, unchanged, skip. fontSize, unchanged, skip. transform, changed → one write: conceptually p.style.transform = 'translateX(10px)'. A style object with ten properties and one change costs one write, not ten.
3. Text is set on the text node, never via innerHTML.
import { useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
What happens at commit: the changed string lives in its own text node, and React updates it like textNode.nodeValue = 'Count: 1'. Never innerHTML. Setting innerHTML would make the browser parse the string, destroy the existing text node, and create new ones, plus open an HTML-injection hole if the text came from a user. nodeValue treats text as text: one write, no parsing, no escaping worries. (This is also why putting HTML in a string child safely displays as literal characters.)
Insertions: build off-screen, attach once
When a render creates new DOM, say, prepending 50 rows to a list, React does not append row 1, then row 2, then row 3… Each attachment would force the browser to recalculate layout around a half-built list, dozens of times.
Instead:
- The whole subtree is built in memory first. Every new row's DOM node is created and wired together detached, invisible, layout-free. Building in memory is pure JavaScript speed.
- One attachment. The completed subtree is inserted at the right position with a single
appendChild/insertBeforeon the parent.
The browser sees the list go from old to new in one layout and paint, not fifty. The fiber tree made this natural: the new subtree was already fully assembled in the draft; commit just materializes it.
Deletions: cleanup before the cut
Removing a subtree isn't just removeChild. Inside the doomed subtree there may be refs and effects that must be released in order:
- Effects are cleaned up, every effect cleanup function in the subtree runs (close the websocket, clear the timer).
- Refs are detached,
ref.currentis set back tonull, so your code never holds a pointer to a dead node. - The DOM node is removed, one
removeChildon the parent takes the whole subtree with it.
Bottom line for you: by the time a node vanishes from the page, your components have already been given the chance to say goodbye, and no ref dangles.
Moves: insertBefore at the right sibling
A "move" (from the watermark logic in chapter 3) is just a placement of an existing node. DOM nodes can't be in two places, so parent.insertBefore(existingNode, beforeThisSibling) is the move, the node relocates in one operation, state and focus intact, no recreation.
The subtlety is finding beforeThisSibling: React walks forward from the moved fiber's new position to the nearest host sibling that isn't itself being moved, and inserts before it. Component fibers have no DOM node, so the search skips over them to real elements, one more place where the fiber tree's mixed nature is quietly handled for you.
The golden order: measure, then mutate
Commit's internal ordering exists to defeat a classic browser trap:
Jargon: "layout thrashing". The tax you pay when JavaScript alternates reading layout (e.g.
getBoundingClientRect) with writing the DOM. Every read after a write forces a fresh layout calculation. Interleave ten reads and ten writes and you may get ten layouts instead of one.
So React separates commit into strict phases, all reads first, all writes after:
- Read phase ("before mutation"). Snapshot reads happen here: capture scroll positions, run the class-component snapshot hook, anything that must observe the old layout before it changes.
- Mutate phase. Every flagged deletion, update, placement, and move is applied to the DOM, back to back, no reads interleaved. One layout.
- Refs and layout effects. Only after the DOM is final:
ref.currentattaches, layout-effect cleanups-then-effects run (they may read the new layout and even trigger one more synchronous commit if they set state). - Paint. The browser draws the new frame.
- Passive effects. Your
useEffectcleanups and effects run after paint, they must never block the user from seeing the update. (Part 3 gives them their own chapter.)
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: Updating a component rewrites its whole DOM subtree. Reality: updates are attribute-level: one changed class is one write, one changed style key is one write, one changed text is one
nodeValue. - Misconception: React sets text with
innerHTML. Reality: text children go throughnodeValue, no parsing, no re-created nodes, no injection surface. - Misconception: The DOM is modified during render. Reality: render only flags fibers; all mutation happens in the uninterruptible commit.
- Misconception:
useEffectruns before the browser paints. Reality: passive effects run after paint; only layout effects run before it. - Misconception: Inserting many rows costs many layouts. Reality: subtrees are built detached and attached in one operation, one layout, one paint.
- Misconception: Refs are available during render. Reality: refs attach in commit, after the DOM is final;
ref.currentduring render is stale or null.
Why it works this way
- DOM work is the bottleneck; JS work is cheap. Flags turn "figure out what changed" into a precomputed checklist, so commit spends zero time thinking and minimal time touching.
- Writes batched, reads first = one layout. The read/mutate split structurally prevents layout thrashing, even when snapshots and layout effects need measurements.
- Detached construction amortizes insertion cost. Building in memory is fast; the browser pays for layout once, at a single attachment point.
- Cleanup-before-removal keeps resources sane. Effects and refs are released while the subtree still exists, so nothing leaks and nothing dangles.
- Commit is uninterruptible because half-applied DOM is the enemy. Render may pause; the moment real pixels are at stake, React finishes the job in one synchronous burst.
Try it yourself
- In the className example, open DevTools → Elements, right-click the button → Break on → attribute modifications. Click it: the debugger pauses on exactly one class change, no other DOM activity.
- In the style example, break on attribute modifications for the
<p>and click "Move" repeatedly: onlystylemutates;colorandfontSizeare never rewritten. - Render a button that prepends 100 rows to a keyed list. Record the click in DevTools' Performance panel: you'll see one Recalculate Style / Layout for the insertion, not a hundred.
- Add
console.logto a layout effect and a passiveuseEffectin the same component, then trigger an update. The layout effect logs synchronously right after commit, before paint; the passive effect logs after. The timeline from this chapter, live.
Recap
- During render, React marks fibers with effect flags: insert, update, delete, move. Commit just executes the checklist.
- Updates are surgical: changed className → one write; style diffed key by key; text via
nodeValue, neverinnerHTML. - New subtrees are built fully in memory, then attached with a single insert, one layout, one paint.
- Deletions clean up first: effect cleanups run, refs detach, then the node is removed.
- Moves are
insertBeforebefore the nearest stable host sibling, the node relocates intact. - Commit ordering: reads (snapshots) → mutations → refs + layout effects → paint → passive effects. Reads-before-writes prevents layout thrashing.