Render and Commit
What you'll learn
- The two distinct phases every React update passes through, and what happens in each
- Why the render phase must stay pure, and what "interruptible" buys you
- Why the commit phase is synchronous and atomic, users never see a half-applied UI
- Where event handlers fit (neither phase, they trigger the whole thing)
- Batching: why three
setStatecalls cost one render and one commit
You click a button. A moment later the screen shows something new. What happened in between is not "the state updated the DOM", it's a precise two-phase pipeline. Understanding it explains almost every "weird" React behavior you'll ever hit: double logs in development, batched updates, effects that run "late," and the concurrency features waiting in Part 4.
The two phases
Every React update passes through exactly two phases:
Jargon: "render phase". React calls your component functions, builds the new description tree, and diffs it against the current one to compute a list of changes. Pure calculation. Invisible to the user. No DOM is touched. Jargon: "commit phase". React takes that change list and applies it to the real DOM, then attaches refs and runs layout effects. Synchronous, atomic, visible.
| Render | Commit | |
|---|---|---|
| What happens | Call components, build + diff description | Apply minimal DOM changes |
| DOM writes | Zero | All of them, in one block |
| Visible to user? | No | Yes (once complete) |
| Can be paused or discarded? | Yes | Never |
| Your code must be… | Pure | (your code barely runs here) |
Timeline: one click, start to finish
Follow a single state update through the machinery:
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
console.log('render, count =', count);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
What happens:
- The event handler runs. Note carefully: the handler is neither phase. It's the trigger, ordinary imperative code running because the user clicked. Its
setCount(count + 1)does not render anything and does not touch the DOM. It enqueues an update. - The handler finishes. React now processes its update queue. (If the handler had queued five updates, they'd all be processed together, batching, below.)
- Render phase begins. React calls
Counter()from the top with the new state. Yourconsole.log('render, count =', 1)fires. The function returns a fresh description: "a button whose text isCount: 1." - Still in render: the diff. React compares the new description against the current tree. The result is a minimal change list: "update this button's text node from
Count: 0toCount: 1." So far, zero DOM writes. The screen still shows the old UI. - Commit phase begins. React walks the change list and performs the writes: one text-node update. This block is synchronous and atomic, it cannot pause halfway.
- Post-commit bookkeeping. Refs are attached and layout effects run; passive effects run shortly after paint. All in later chapters.
- The browser paints. Only now can the user see
Count: 1.
Read steps 4 and 5 again: the entire point of the render phase is to decide what to change before changing anything.
Why render must be pure: discardable work
The render phase has a superpower: it's allowed to not happen. React may start a render, pause it to handle something urgent, restart it from scratch, or throw the result away entirely. You'll meet the machinery in Part 4, and you've already met a tame version: StrictMode's double render in development.
That superpower is only safe because of the purity rules from the previous chapter. A render is a sketch: if it's wrong, crumple it and sketch again, no harm done. But if sketching had side effects (a network request fired, a module variable pushed), crumpling the sketch wouldn't undo them. Purity is what makes renders disposable. The dev-time double render is React's audit: render twice, and impure code produces visibly wrong results.
Why commit must be atomic: no half-painted lies
Imagine commit could pause: the counter text updates to 3, then React yields, and the browser paints before the matching list item appears. For one frame, the UI contradicts itself. Users absolutely notice.
So commit runs as one synchronous, uninterruptible block: every DOM write in the change list is applied, completely, before the browser gets a chance to paint.
Jargon: "atomic". All-or-nothing. The change list is applied in full or not at all; no observer can catch the UI in an in-between state.
This is the second half of the split's genius: render is interruptible (flexible, restartable, pure), commit is atomic (rigid, final, consistent). Each phase gets exactly the property it needs.
Batching: the free consequence
Here's the payoff you use every day without noticing:
import { useState } from 'react';
export default function Signup() {
const [name, setName] = useState('');
const [agreed, setAgreed] = useState(false);
const [attempts, setAttempts] = useState(0);
console.log('rendered');
function handleSubmit() {
setName('');
setAgreed(false);
setAttempts(a => a + 1);
}
return (
<form onSubmit={e => { e.preventDefault(); handleSubmit(); }}>
<input value={name} onChange={e => setName(e.target.value)} />
<label>
<input
type="checkbox"
checked={agreed}
onChange={e => setAgreed(e.target.checked)}
/>
I agree
</label>
<button type="submit">Submit (attempts: {attempts})</button>
</form>
);
}
What happens when you click Submit:
handleSubmitruns, threesetStatecalls. No renders yet. Each call just adds an update to the queue.- The handler finishes. React drains the queue and applies all three updates in one render,
console.log('rendered')fires once, not three times. - One commit applies whatever actually changed. One paint.
If every setState triggered its own render and commit, this would cost three full passes and could paint intermediate states (name cleared but attempts not yet bumped). Because React controls both phases, it can, and does, insist: finish collecting updates, render once, commit once. Modern React batches this way in every context: event handlers, timeouts, promises, fetch callbacks.
Pseudocode model, not real source:
function handleEvent(userHandler) {beginCollectingUpdates();userHandler(); // setState calls only enqueueconst queue = endCollectingUpdates();if (queue.length > 0) {const changes = renderPhase(queue); // pure, interruptible, no DOMcommitPhase(changes); // sync, atomic, all DOM writes}}
What runs where: the cheat sheet
- Event handlers, neither phase. They trigger updates; side effects are welcome here.
- Component bodies, render phase. Pure. Possibly discarded.
- The diff, render phase. React-internal.
- DOM writes, refs, layout effects, commit phase, in that order.
- Passive effects (
useEffect), after commit, usually after paint. Part 3 has the details.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception:
setStateupdates the DOM. Reality: it enqueues an update. The DOM changes only at commit, after a full render pass, which is why reading the DOM right aftersetStateshows the old value. - Misconception: Rendering means the user sees something. Reality: render is invisible calculation. Visibility arrives at commit + paint.
- Misconception: Event handlers are part of the render phase. Reality: handlers run before render begins, they're the trigger, outside both phases, and they're allowed to have side effects.
- Misconception: Each
setStatecauses its own render. Reality: updates are batched, one queue, one render, one commit, even across awaits and timeouts. - Misconception: Commit can be paused like render. Reality: commit is deliberately synchronous and atomic. Interruptibility is render's privilege, and it's bought with purity.
- Misconception: The dev double-render means something is broken. Reality: it's a purity audit, proof that renders are safe to discard and redo.
Why it works this way
- The split separates "what should be" from "making it so." One phase computes intent; the other executes it. Each can be optimized independently.
- Purity in render buys interruptibility. Because renders are discardable, React can prioritize urgent updates over slow ones, the entire concurrency story of Part 4 rests on this.
- Atomic commit buys visual consistency. Users never observe a UI that's halfway between two states, because no paint can sneak into the middle of a commit.
- Batching buys both speed and correctness. One render per event is faster, and it also prevents intermediate states from ever reaching the screen.
- Effects after commit guarantee the DOM exists. Anything that touches the outside world runs only after the world matches the description.
Try it yourself
- In the Counter example, keep the render
console.logand addconsole.log('handler ran')in the click handler. Click once and read the order: handler first, render second. That's trigger → phase, in your own console. - Add three
setStatecalls to one handler and log inside the component body. Observe: the body logs once per click. Then spread the same calls across two separate clicks and watch it log twice. - In a handler, call
setCount(count + 1)and immediately read the button's text from the DOM (via a ref ordocument.querySelector). It still shows the old text, commit hasn't happened yet while the handler is running. - Run a StrictMode dev build and watch the render log fire twice per update while the DOM updates once. You are watching React prove that renders are discardable.
Recap
- Every update flows: trigger (event handler) → render phase (pure, interruptible, zero DOM writes) → commit phase (synchronous, atomic, all DOM writes) → paint.
setStateenqueues an update; it never renders or touches the DOM by itself.- Render purity is what makes renders discardable, the foundation of concurrency in Part 4.
- Commit atomicity is why users never see a half-updated UI.
- Batching falls out of the model for free: one queue, one render, one commit per event, everywhere.
- Event handlers belong to neither phase; they're the trigger, and they're where side effects are welcome.