EdgeCases Logo
Feb 2026
React
Deep
7 min read

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

react
bailout
rendering
performance
optimization
object-is
edge-case

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 optimization

React 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 reference

Key Takeaways

  • Object.is comparison 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
  • lanes mark where updates are; childLanes mark the path to them
  • memo() requires stable prop references—use useCallback/useMemo

Advertisement

Related Insights

Explore related edge cases and patterns

React
Expert
React Key Prop: The Reconciliation Deep Dive
8 min
React
Deep
React Concurrent Rendering Tearing: When External Stores Break
7 min
React
Deep
React useId: Why Math.random() Breaks Hydration
7 min

Advertisement