⚛ The Course  /  Module 2 · One Pass of Work · 2.4
Module 2 · One Pass of Work

2.4 Bailout: when React skips a component

You click a button, one component calls setState, and React starts an update. But which components actually re-render? Not all of them — and the rule surprises almost everyone. This lesson is about bailout: how React decides to skip a component completely and reuse last render's work, without ever calling the component function.

Every fiber either renders or bails out

As React walks the tree in beginWork (lesson 2.1), each fiber takes one of two paths:

  • render — call the component function, get fresh JSX, and reconcile its children.
  • bailout — decide nothing changed, skip the function, and reuse last render's child fibers.

The key part: a bailed-out component is never called. Its console.log does not fire, and its children are not re-created. React just clones the existing child fibers and keeps going.

beginWork(fiber)
   │
   ├─ inputs changed?  ──yes──►  RENDER   (run the component, build new JSX)
   │
   └─ nothing changed? ──yes──►  BAILOUT  (skip the function, clone old children)

The four conditions for a bailout

A fiber bails out only when all four of these are true:

  1. oldProps === newProps — the same props object. This is reference equality (===), not a deep or value comparison.
  2. Context didn't change — no context this fiber reads has a new value.
  3. The type didn't change — e.g. it's still a <div>, not swapped for a <p>.
  4. This fiber has no pending update of its own at the priority being rendered. (This one is about Lanes — Module 3. In plain terms: the component that called setState must re-render; a component with no update of its own can be skipped.)

If all four hold, React bails out. If any one fails, it renders.

The catch: props is a brand-new object every render

Here is what trips people up. Condition 1 is reference equality — but JSX compiles to React.createElement, which builds a new props object every single render. So normally oldProps !== newProps, and the component renders.

<Child name="alice" />
// compiles to:
React.createElement(Child, { name: "alice" })
//                         └── a NEW object literal, created on every render

That is exactly why a parent re-render normally re-renders all of its children: each child gets a fresh element carrying a fresh props object, so condition 1 fails for every one of them. (This is also the reason React.memo exists — see the gotcha.)

The surprise: children can stay identical

But there is one case where oldProps === newProps with no memo at all — when a component's element comes from a parent that itself bailed out. The element object is reused, so its props object is reused too.

This is the classic demo. Click the <div> to bump count — does Son print child render!?

function Son() {
  console.log('child render!');
  return <div>Son</div>;
}

function Parent(props) {
  const [count, setCount] = React.useState(0);
  return (
    <div onClick={() => setCount(count + 1)}>
      count:{count}
      {props.children}
    </div>
  );
}

function App() {
  return (
    <Parent>
      <Son />
    </Parent>
  );
}

It does not. Walk the update, fiber by fiber:

click → only Parent calls setState

RootFiber   → bailout → returns the SAME App element
  App       → bailout → returns the SAME Parent element (App's props didn't change)
    Parent  → RENDERS  → it owns the update, so condition 4 fails
      Son   → bailout → its element is props.children, reused from App's bailout
                        so oldProps === newProps → "child render!" never prints

The trick is in Parent's render. {props.children} is Son's element — but props here is Parent's props, which came from App's bailed-out render. Because App didn't re-run, that element object is the same one as last time. So Son's fiber sees the identical props object, condition 1 is met, and Son bails out.

What bailout actually saves

A bailout is cheap on two fronts: the component function never runs (so no new elements are created), and React only does a shallow clone of the existing child fibers.

The real code

First, the reference check inside beginWork that sets the "did anything change?" flag:

// beginWork — the props reference check
const oldProps = current.memoizedProps;        // from the last render
const newProps = workInProgress.pendingProps;  // from this render

if (oldProps !== newProps) {   // reference comparison, NOT deep equality
  didReceiveUpdate = true;     // something changed -> render
}

And the bailout path itself — notice it clones children and returns, without ever calling the component:

// the bailout path — clones children, returns, never calls the component
function bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) {
  // ...
  // Clone the children WITHOUT running the component or creating new elements
  cloneChildFibers(current, workInProgress);
  return workInProgress.child;
}
Show how React.memo changes the check (shallowEqual)

Plain function components compare props with ===, which a new props object always fails. React.memo swaps that for shallowEqual — it compares each prop value instead of the props object's identity:

// memo's compare
var compare = Component.compare !== null ? Component.compare : shallowEqual;
if (compare(prevProps, nextProps)) {
  return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}

function shallowEqual(objA, objB) {
  if (Object.is(objA, objB)) return true;        // same reference
  const keysA = Object.keys(objA), keysB = Object.keys(objB);
  if (keysA.length !== keysB.length) return false;
  for (let i = 0; i < keysA.length; i++) {
    if (!objB.hasOwnProperty(keysA[i]) ||
        !Object.is(objA[keysA[i]], objB[keysA[i]])) {
      return false;
    }
  }
  return true;
}

So with memo, a new props object is fine as long as each value inside is the same reference. An inline object, array, or function prop (style={{...}}, onClick={() => ...}) is a new reference every render, so it still defeats memo.