Rendering With vs Without the Compiler
The reconciler has always had a cheap bailout: if a fiber's props object is the same reference as last time and it has no pending work, beginWork skips it and possibly its whole…
- Published
- Reading time
- 10 min read
The compiler mechanics are in React Compiler. This page follows one update through beginWork & Reconciliation in both modes.
The example app
function ExpensiveChild({ data }) {
const rows = data.map((d) => <Row key={d.id} value={d.value} />);
return <section>{rows}</section>;
}
function StaticChild() {
return <footer>Static footer</footer>;
}
export function Parent({ data }) {
const [count, setCount] = useState(0);
return (
<main>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<ExpensiveChild data={data} />
<StaticChild />
</main>
);
}This is what babel-plugin-react-compiler@1.0.0 actually emits for Parent. The output is verbatim and the comments are mine. (Source: compile-demo/examples/Parent.jsx → out-Parent.js in the scratchpad.)
export function Parent(t0) {
const $ = _c(9);
const { data } = t0;
const [count, setCount] = useState(0);
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = () => setCount(_temp2); // handler created once (setCount is stable)
$[0] = t1;
} else { t1 = $[0]; }
let t2;
if ($[1] !== count) {
t2 = <button onClick={t1}>{count}</button>; // rebuilt when count changes
$[1] = count; $[2] = t2;
} else { t2 = $[2]; }
let t3;
if ($[3] !== data) {
t3 = <ExpensiveChild data={data} />; // cached on `data`
$[3] = data; $[4] = t3;
} else { t3 = $[4]; }
let t4;
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
t4 = <StaticChild />; // no deps: created ONCE per fiber lifetime
$[5] = t4;
} else { t4 = $[5]; }
let t5;
if ($[6] !== t2 || $[7] !== t3) {
t5 = <main>{t2}{t3}{t4}</main>;
$[6] = t2; $[7] = t3; $[8] = t5;
} else { t5 = $[8]; }
return t5;
}
function _temp2(c) { return c + 1; } // updater outlined to module scope(I joined some lines; the statements are unchanged.) ExpensiveChild is compiled too: rows is cached on data, and the <section> on rows. The map callback captures nothing reactive, so it is outlined as _temp. StaticChild becomes a single sentinel-guarded <footer>.
The bailout that makes it work
All the gain comes from one check at the top of beginWork:
// packages/react-reconciler/src/ReactFiberBeginWork.js (simplified)
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (oldProps !== newProps || hasLegacyContextChanged() /* || DEV hot reload */) {
didReceiveUpdate = true;
} else {
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(current, renderLanes);
if (!hasScheduledUpdateOrContext && (workInProgress.flags & DidCapture) === NoFlags) {
didReceiveUpdate = false;
return attemptEarlyBailoutIfNoScheduledUpdate(current, workInProgress, renderLanes);
}
// ...
}
}That's the full rule.1 Note that the comparison is reference equality on the whole props object, not a shallow compare. checkScheduledUpdateOrContext asks whether this fiber has an update in the lanes being rendered (current.lanes), or whether a context it read changed.2 If not, the early bailout ends in bailoutOnAlreadyFinishedWork. If childLanes doesn't intersect renderLanes either, it returns null and the entire subtree is skipped. Otherwise it clones the children and keeps walking down to the fiber that actually has work.3
Where do pendingProps and memoizedProps come from?
- When the parent reconciles its children and finds an element of the same type, it calls
useFiber(current, element.props), which creates the work-in-progress withpendingProps = element.props.45 - After
beginWorkfinishes,performUnitOfWorksetsmemoizedProps = pendingProps.6
So memoizedProps is literally the props object of the element rendered last time. If the parent hands over the same element object again, newProps === oldProps holds automatically. JSX compiles to jsx(type, props), which allocates a new element and a new props object on every call (Bootstrap), so uncompiled code never hands over the same object. Compiled code does, whenever the scope's dependencies didn't change.
First render: same path, plus cache filling
On mount current === null for every fiber, so the bailout block doesn't apply in either mode. Every component runs, every fiber is created, and completeWork builds every DOM node.
The compiler adds this on top:
_c(9)→useMemoCache(9)finds no cache on the fiber or its alternate, allocates{data: [], index: 0}, puts it onfiber.updateQueue.memoCache, and fills a 9-slot array withREACT_MEMO_CACHE_SENTINEL.7- Every scope runs.
$[i] !== depis true because the slot holds the sentinel, and sentinel-guarded scopes are true by definition. The scope writes its deps and outputs into the slots. - The extra cost is one array per compiled component instance plus the stored deps and outputs: a few comparisons and stores per scope. The compiler's stated goal is to "keep startup time neutral".8 An independent audit of a 15k-line app measured no visible initial-load impact.9
Otherwise the path is identical: root.render → Work Loop → Commit Phase.
State change: click the button
setCount → dispatchSetState marks the Parent fiber with a lane and bubbles childLanes to the root (setState). The render starts at the root, and every ancestor of Parent bails out with oldProps === newProps plus childLanes set, which clones down to Parent. That part is identical in both modes.
Parent itself has a pending lane, so it renders in both modes. updateReducer sees a new count, which differs (!is(new, old)), and calls markWorkInProgressReceivedUpdate().10 This is covered in Hooks on Update.
Without the compiler
Parent() returns a fresh <main> holding a fresh <button>, a fresh <ExpensiveChild data={data}/> (new props object, same data), and a fresh <StaticChild/> (new, empty {} props object).
flowchart TD
P["Parent<br/>RENDERS (has lane)"]:::render --> M["main (host)<br/>new props → reconcile"]:::render
M --> B["button (host)<br/>new props → update text"]:::render
M --> E["ExpensiveChild<br/>RENDERS: oldProps !== newProps"]:::render
M --> S["StaticChild<br/>RENDERS: {} !== {}"]:::render
E --> SEC["section<br/>reconcile"]:::render
SEC --> R1["Row × N<br/>ALL RENDER"]:::render
S --> F["footer<br/>reconcile"]:::render
classDef render fill:#f8d7da,stroke:#c0392b,color:#000;
classDef bail fill:#d4edda,stroke:#27ae60,color:#000;Every function component below Parent runs: ExpensiveChild re-maps data and creates N new <Row> elements, each Row runs, and so on down. Every host fiber in that subtree (<section>, whatever Row renders, <footer>) also gets a new props object. completeWork's updateHostComponent only checks oldProps === newProps, so it flags each of them Update.11 In the commit phase, commitUpdate → updateProperties then diffs old against new props for every flagged node.12 Nearly all of those diffs find nothing to change, so the only real DOM write is the button's text, but you still paid for all the diffing. (There is no HostText fiber here. <button>{count}</button> has a single primitive child, so React treats it as the children prop: updateProperties → setTextContent. The uncompiled build also re-assigns onClick on every render because the arrow function is new each time. The compiled build skips that because the handler is cached. See setState Walkthrough.) The only standard fix is React.memo, which turns the identity check into shallowEqual(prevProps, nextProps) in updateSimpleMemoComponent. It only works if every prop is stable, which in turn needs useCallback/useMemo in the parent.13
With the compiler
Parent() still runs, because it has the update. Inside it:
$[1] !== countis true, so a new<button>element is built.$[3] !== datais false, sot3is the same<ExpensiveChild>element object as last render.t4is the same<StaticChild>element object (sentinel already replaced).t2changed, so a new<main>is built.
flowchart TD
P["Parent<br/>RENDERS (has lane), cache hits for t1,t3,t4"]:::render --> M["main (host)<br/>new props (t2 changed) → reconcile"]:::render
M --> B["button (host)<br/>new element → update text"]:::render
M --> E["ExpensiveChild<br/>BAILOUT: oldProps === newProps, no lanes"]:::bail
M --> S["StaticChild<br/>BAILOUT: same element"]:::bail
E -.-> SEC["section + Row × N<br/>NOT VISITED (childLanes = 0)"]:::skip
S -.-> F["footer<br/>NOT VISITED"]:::skip
classDef render fill:#f8d7da,stroke:#c0392b,color:#000;
classDef bail fill:#d4edda,stroke:#27ae60,color:#000;
classDef skip fill:#eeeeee,stroke:#999,color:#555,stroke-dasharray: 4 3;<main> reconciles its array of children. For ExpensiveChild and StaticChild it calls useFiber(current, element.props) with the cached element's props, which are the very object stored as memoizedProps last time. In beginWork for those fibers, oldProps === newProps, no lane, and no context change, so attemptEarlyBailoutIfNoScheduledUpdate → bailoutOnAlreadyFinishedWork returns null. The component function never runs, and its subtree is never visited.13 completeWork still runs for the bailed-out fiber itself (performUnitOfWork completes any unit whose beginWork returned null), but its descendants are never touched.6
Second line of defence: compiled children
Suppose ExpensiveChild does render, say because data changed or because it reads a context that changed. Its own cache still helps. rows is recomputed only if data changed, and if ExpensiveChild re-renders for an unrelated reason (a context it reads), rows and the <section> come from $ and every Row bails out. Without the compiler, a re-render always cascades. With it, a re-render stops at the first level whose inputs didn't change. That covers most of what React.memo was used for. It is not identical: the compiled component's function still runs (cheaply) when its parent hands it a new element whose props are shallow-equal, whereas React.memo would skip the function call.
Comparison table
Effects deps stability, from real output
function EffectDeps({ userId }) {
const options = { userId, retry: 3 };
useEffect(() => { subscribe(options); }, [options]);
return <i>{userId}</i>;
}Uncompiled, options is new on every render, so the effect re-subscribes every render. The classic bug. Compiled (verbatim, trimmed):
if ($[0] !== userId) { t1 = { userId, retry: 3 }; $[0] = userId; $[1] = t1; } else { t1 = $[1]; }
const options = t1;
if ($[2] !== options) { t2 = () => { subscribe(options); }; t3 = [options]; /* ... */ } else { /* reuse */ }
useEffect(t2, t3);Now options changes only when userId changes, so the effect fires only then. The flip side is in the debugging guide: code that accidentally relied on memoization (or on its absence) can behave differently, for example effects over-firing or under-firing once the compiler memoizes differently than you did.14
Gotchas: where the two modes really differ
I checked each of these against real 1.0.0 output (examples/Gotchas.jsx → out-Gotchas.js).
- Non-idempotent render code gets frozen.
const t = Date.now(); return <span>{t}</span>;compiles into a sentinel-only scope. The timestamp is computed once per mount and never changes, even when the parent re-renders. Uncompiled, every render shows a new time. Render must be pure. Read clocks and randomness in effects or event handlers, or keep them in state. - External mutable values.
<span>{globalCounter.n}</span>with a module-level mutable object also compiles to a sentinel-only scope, because a module global isn't reactive. MutatingglobalCounter.nand re-rendering shows the stale value forever. Uncompiled, it "worked" by accident. Use state, context, oruseSyncExternalStore. - Reading refs during render.
ref.currentin JSX is a validation error, and the compiler skips the component rather than cache a stale read (see the bailout table in React Compiler). - Undetected mutation. Mutating a prop through a method call (
items.push) compiled fine. The compiler trusts the Rules of React where it can't prove a violation. - Libraries relying on referential instability or interior mutability. APIs like
react-hook-form'swatch(), or MobXobserverobjects, return the same reference while their contents change. Memoized code never sees the change, and the UI looks frozen. Known APIs are flagged by theincompatible-librarylint and the component is skipped. MobX is not detected, so use"use no memo".16 A real-app audit found components skipped because of a single external-library call, which is the practical ceiling on the gains.9 - Memoization isn't shared. Two components calling
expensive(items)each compute it once per their own cache. The compiler memoizes per fiber, not globally.15
What is unchanged
The scheduler, lanes, the work loop, beginWork's logic, child reconciliation, completeWork, commit, and effect flushing run the same code. Even the cache is just another hook (useMemoCache) on the fiber. With the compiler, the existing bailout paths simply fire far more often, because compiled components produce stable element identities.
Summary
The reconciler has always had a cheap bailout: if a fiber's props object is the same reference as last time and it has no pending work, beginWork skips it and possibly its subtree. Uncompiled code rarely hits it below a re-rendering parent, because every jsx() call allocates a new props object. Compiled components return cached elements, so oldProps === newProps holds and unchanged subtrees are never visited. The state owner still renders and the real DOM writes are the same; the win is skipped renders and skipped prop diffing. This closes the pair begun in React Compiler.
Key takeaways
- The
beginWorkbailout: same props reference (not a shallow compare) and no pending work; ifchildLanesis clear too, the subtree is skipped. useFiber(current, element.props): the element's props becomememoizedProps, so the same element passes the check automatically.- First render:
current === nulleverywhere, so both modes do the same work plus filling the cache. - Without the compiler: every component below the state owner renders, and every host fiber there is flagged
Updateand diffed. - With the compiler:
ExpensiveChildandStaticChildget cached elements, bail out, and their subtrees go unvisited. - Second line of defence: a compiled child that re-renders reuses its own cache; close to
React.memo, not identical. - Effect deps: render-created objects are memoized on their real inputs, so effects fire only when those change.
- Gotchas:
Date.now()in render, mutable globals and interior-mutable libraries freeze; try"use no memo"first.
Footnotes
-
beginWorkprops/lane bailout — packages/react-reconciler/src/ReactFiberBeginWork.js#L4250-L4294 (opens in a new tab) ↩ ↩2 -
checkScheduledUpdateOrContext— packages/react-reconciler/src/ReactFiberBeginWork.js#L3939-L3956 (opens in a new tab) ↩ -
bailoutOnAlreadyFinishedWork(returnsnullwhenchildLanesdon't intersectrenderLanes) — packages/react-reconciler/src/ReactFiberBeginWork.js#L3826-L3865 (opens in a new tab);attemptEarlyBailoutIfNoScheduledUpdatestarts at L3958 (opens in a new tab) ↩ ↩2 -
updateElement→useFiber(current, element.props)— packages/react-reconciler/src/ReactChildFiber.js#L580-L627 (opens in a new tab);useFiberL502-L508 (opens in a new tab) ↩ -
createWorkInProgresssetspendingProps— packages/react-reconciler/src/ReactFiber.js#L323-L353 (opens in a new tab) ↩ -
performUnitOfWork:memoizedProps = pendingProps, complete onnull— packages/react-reconciler/src/ReactFiberWorkLoop.js#L3081-L3119 (opens in a new tab) ↩ ↩2 -
useMemoCache— packages/react-reconciler/src/ReactFiberHooks.js#L1181-L1263 (opens in a new tab) ↩ ↩2 -
Compiler design doc, Goals — compiler/docs/DESIGN_GOALS.md#L5-L15 (opens in a new tab) ↩
-
Nadia Makarevich, "How React Compiler Performs on Real Code": https://dev.to/adevnadia/how-react-compiler-performs-on-real-code-5gkf (opens in a new tab) ↩ ↩2
-
updateReducerImpl→markWorkInProgressReceivedUpdatewhen state changed — packages/react-reconciler/src/ReactFiberHooks.js#L1551-L1555 (opens in a new tab); post-render bailout inupdateFunctionComponentpackages/react-reconciler/src/ReactFiberBeginWork.js#L1549-L1552 (opens in a new tab) ↩ -
updateHostComponent(completeWork) — packages/react-reconciler/src/ReactFiberCompleteWork.js#L463-L481 (opens in a new tab) ↩ -
commitUpdate→updateProperties— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L999-L1012 (opens in a new tab) ↩ -
updateSimpleMemoComponent(shallowEqual) — packages/react-reconciler/src/ReactFiberBeginWork.js#L547-L617 (opens in a new tab) ↩ -
react.dev, "Debugging and Troubleshooting": https://react.dev/learn/react-compiler/debugging (opens in a new tab) ↩ ↩2 ↩3
-
react.dev, "React Compiler: Introduction": https://react.dev/learn/react-compiler/introduction (opens in a new tab) ↩ ↩2
-
react.dev,
incompatible-librarylint: https://react.dev/reference/eslint-plugin-react-hooks/lints/incompatible-library (opens in a new tab) ↩