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