React Render Bailout: When React Secretly Skips Your Update
When React silently skips your re-render—Object.is, lanes, and the children-as-props escape hatch
You set state to the same value and your component still re-renders. Or you expected a re-render
but React silently skipped it. React's bailout optimization is powerful but poorly documented—it
uses Object.is comparisons, lanes, and childLanes to decide when to skip work. Here's
when React actually renders and when it secretly doesn't.
The Same-Value Bailout
function Counter() {
console.log('render'); // When does this log?
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(0)}>
{count}
</button>
);
}
Click the button. The component renders... once. Click again. Nothing. React sees that
Object.is(0, 0) is true and bails out. But here's the catch:
// First click with count=0:
// React queues the update, then compares new vs old
// Object.is(0, 0) === true → bailout, but...
// The component MIGHT still render once to verify
// This is intentional! React needs to verify
// that the result is the same before committing
// to the optimizationReact may render your component one extra time after a same-value setState, but it won't commit the result to the DOM. This "eager bailout" behavior catches edge cases where side effects during render could change outcomes.
Object.is, Not ===
// Object.is handles edge cases === doesn't
Object.is(NaN, NaN); // true (=== would be false)
Object.is(0, -0); // false (=== would be true)
Object.is({}, {}); // false
// This means:
setCount(NaN);
setCount(NaN); // Bails out! NaN is the "same"
setCount(-0);
setCount(0); // Does NOT bail out! -0 !== 0
The Object.is comparison happens on the state value, not props or context.
For objects and arrays, reference equality is all that matters:
const [user, setUser] = useState({ name: 'Alice' });
// ❌ Always re-renders (new reference)
setUser({ name: 'Alice' });
// ✅ Bails out (same reference)
setUser(user);
setUser(prev => prev);Why Children Re-Render Anyway
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
<Child /> {/* Re-renders every time! */}
</div>
);
}
function Child() {
console.log('child render');
return <div>I'm a child</div>;
}
Click the button and Child re-renders even though it has no props. Why?
Because <Child /> creates a new React element on every Parent render.
React sees pendingProps !== memoizedProps (different object references) and
proceeds with the update.
The Children-as-Props Pattern
function Parent({ children }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
{children} {/* Now this bails out! */}
</div>
);
}
// Usage:
<Parent>
<Child />
</Parent>
Now Child is created in the parent's parent, not in Parent.
When Parent re-renders, children is the same reference (passed via props),
so React bails out on Child.
React's Internal Bailout Logic
// Simplified from ReactFiberBeginWork.js
function beginWork(current, workInProgress, renderLanes) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
// Step 1: Props changed?
if (oldProps !== newProps) {
didReceiveUpdate = true;
} else {
// Step 2: Scheduled update on this fiber?
const hasScheduledUpdate = checkScheduledUpdateOrContext(
current, renderLanes
);
if (!hasScheduledUpdate) {
// Step 3: Can we bail out completely?
didReceiveUpdate = false;
return attemptEarlyBailoutIfNoScheduledUpdate(/*...*/);
}
}
// Proceed with render...
}
React checks three things in order: (1) props reference equality, (2) scheduled updates
(via lanes), and (3) whether children have work (childLanes).
If all pass, the entire subtree is skipped.
Lanes: React's Priority System
// When you call setState:
// 1. React marks the fiber with a "lane" (priority)
// 2. All ancestor fibers get "childLanes" marked
// 3. During render, React checks if lanes include current renderLanes
// Fiber tree after setState in C:
//
// A (childLanes: 1)
// └── B (childLanes: 1)
// └── C (lanes: 1, childLanes: 0)
//
// A and B will enter beginWork but bail out
// (no lanes, but have childLanes → continue to children)
// C will actually render (has lanes)
This is why a state change in a deeply nested component doesn't re-render siblings or
ancestors—they have no lanes, only childLanes pointing to the
actual update location.
When memo() Isn't Enough
const Child = memo(function Child({ onClick }) {
console.log('child render');
return <button onClick={onClick}>Click</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// ❌ New function reference every render
const handleClick = () => console.log('clicked');
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<Child onClick={handleClick} /> {/* Still re-renders! */}
</div>
);
}
memo() does a shallow comparison of props. Since handleClick
is a new function reference each time, the comparison fails. Fix with useCallback:
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // Stable referenceKey Takeaways
Object.iscomparison triggers same-value bailout for state- One extra render may happen after bailout (React verifies the optimization)
- Children re-render because JSX creates new element references
- The children-as-props pattern prevents unnecessary re-renders
lanesmark where updates are;childLanesmark the path to themmemo()requires stable prop references—useuseCallback/useMemo
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement