Skip to content
← Journal
7 min readAta Mohammadi

React Compiler Internals: How Auto-Memoisation Works, and Where It Gives Up

The compiler does not memoise your components by wrapping them in `memo`. It rewrites them into a cache-slot machine, and it does it only when it can prove your code follows the rules. Here is the proof it needs, and what breaks it.

React Compiler reached 1.0 in October 2025 after years of running in production at Meta, and the pitch is genuinely appealing: delete your useMemo and useCallback and let a compiler do it, better than you were doing it by hand.

The pitch is also slightly misleading, in a way that matters when you adopt it. The compiler does not memoise your components. It rewrites them, into something that does not look much like what you wrote — and it only does that when it can prove certain things about your code. Understanding what it needs to prove tells you exactly why some component in your codebase did not get optimised, and what to do about it.

What it emits

Take a component you would normally have hand-optimised:

function ProductRow({ product, currency, onSelect }) {
  const price = formatPrice(product.priceMinor, currency);
  const handleClick = () => onSelect(product.id);

  return (
    <li>
      <span>{product.name}</span>
      <span>{price}</span>
      <button onClick={handleClick}>Select</button>
    </li>
  );
}

What comes out is, in essence, a state machine over a cache array:

function ProductRow({ product, currency, onSelect }) {
  const $ = _c(7); // one hook call, a fixed-size slot array per instance

  let price;
  if ($[0] !== product.priceMinor || $[1] !== currency) {
    price = formatPrice(product.priceMinor, currency);
    $[0] = product.priceMinor;
    $[1] = currency;
    $[2] = price;
  } else {
    price = $[2];
  }

  let handleClick;
  if ($[3] !== onSelect || $[4] !== product.id) {
    handleClick = () => onSelect(product.id);
    $[3] = onSelect;
    $[4] = product.id;
    $[5] = handleClick;
  } else {
    handleClick = $[5];
  }

  // …and the JSX itself is cached the same way, in the remaining slots.
}

Three things in that output are worth dwelling on.

Dependencies are at the property level. The hand-written version would have had [product, currency] as its dependency array. The compiler depends on product.priceMinor. If the parent hands down a new product object whose price is unchanged, the hand-written version recomputes and the compiled version does not.

The JSX is memoised too. Not just values and callbacks — the returned element objects go in the cache. That is something useMemo cannot do for you without restructuring the component, and it is where a good share of the measured gains come from.

It is one hook call, not N. Cache slots are indices into one array, so there is no per-useMemo hook overhead, no dependency arrays allocated per render, and no linter to disagree with.

How it decides

The pipeline is a real compiler, not a pattern matcher:

  1. Build an HIR. The function becomes a high-level intermediate representation over a control-flow graph — basic blocks, explicit branches, SSA-style value identity.
  2. Infer mutability. For every value, work out whether it is mutated, where, and by whom. This is the analysis everything else rests on.
  3. Infer reactive scopes. Group values that must be recomputed together into scopes, and compute each scope's true input set.
  4. Codegen. Emit the cache-slot machine above, one scope at a time.

Step 2 is where adoption succeeds or fails. The compiler must prove that caching a value is safe — that nothing later mutates it in a way the cache would hide. When it cannot prove that, it does not guess. It leaves the code alone.

That is the right default and it is also why "I installed it and nothing got faster" happens: silence is the failure mode.

What makes it give up

In rough order of how often you will actually hit it:

Mutating something you did not create. Pushing into a prop array, assigning to a field of a prop object, mutating something from a ref. The compiler cannot see who else holds that reference, so it cannot bound the effect of the mutation.

// Bails: `items` is not ours to mutate.
function List({ items }) {
  items.sort((a, b) => a.order - b.order);
  return items.map(render);
}

// Compiles: the copy is local, so its mutation is fully known.
function List({ items }) {
  const sorted = [...items].sort((a, b) => a.order - b.order);
  return sorted.map(render);
}

That second version is also just correct — the first one mutates the caller's array — which is the general pattern: most bailouts are the compiler declining to preserve a latent bug.

Reading a ref during render. ref.current can change between renders without notifying anyone. A value derived from it cannot be cached soundly.

Conditional hooks, or anything that breaks the rules of hooks. Non-negotiable; it changes the slot layout between renders.

Setting state during render. Outside the narrow, documented "derived state" pattern, this makes render order observable, and the compiler will not reorder around it.

eval, with, and unanalysable dynamism. Same reason V8 gives up on scope optimisation in those cases: the set of things that could happen stops being knowable.

An explicit "use no memo" directive. The escape hatch, for when you have found a genuine miscompile or a component whose behaviour depends on identity in a way the compiler does not model.

Finding out which components bailed

This is the part teams skip, and then wonder why nothing improved. The compiler-derived rules now ship inside eslint-plugin-react-hooks, so the linter tells you:

// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";

export default [reactHooks.configs.flat.recommended];

You get rules like set-state-in-render, set-state-in-effect and unsafe-ref-access as ordinary lint errors, in your editor, before the build. Treat a bailout as a lint failure to fix, not as a compiler shortcoming to accept — the fixes are almost always improvements on their own terms.

Installing it without drama

  • Babel is the primary path: npm install --save-dev --save-exact babel-plugin-react-compiler@latest.
  • Next.js has native swc support from 15.3.1, so no Babel in the pipeline.
  • Vite goes through vite-plugin-react's Babel integration — with the caveat that newer plugin versions swapped Babel for oxc, so on recent Vite you may need to add a Babel plugin explicitly. Native oxc support is in progress.
  • React 17 and 18 are supported with a target setting and the react-compiler-runtime package. It is not React 19-only.
  • React Native is fully supported.

Two adoption rules that are worth following literally. Pin the exact version — memoisation behaviour can legitimately change between releases, and if your test coverage is thin you want that change to be a decision. And roll out behind a gate, directory by directory, measuring as you go. The published numbers (up to ~12% on initial load and navigation, ~2.5× on interactions) are Meta's; yours depend entirely on whether re-rendering was your bottleneck in the first place.

Do you still write useMemo?

Less, but not never. Three cases survive:

Genuinely expensive computation. The compiler memoises for referential stability — to stop downstream re-renders. It is not making a judgement about CPU cost. A 50 ms parse inside a component is a useMemo even after the compiler runs.

Effect dependencies. When a value is in a useEffect dependency array, you want explicit, reviewable control over its identity. That is exactly what useMemo documents.

Third-party APIs keyed on identity. A map library, a chart, an imperative widget that re-initialises when a prop's reference changes. Make that stability explicit rather than relying on an optimisation that may be tuned later.

And for existing code: do not mass-delete your memoisation on day one. The compiler is designed to work alongside it. Remove it where you have tests, leave it where you do not, and stop writing new memoisation by default.

The part that generalises

The interesting thing about React Compiler is not the speedup. It is that a tool with a formal notion of correctness can only optimise code whose behaviour it can bound — and "code whose behaviour can be bounded" is a description of code that is easier for humans to reason about too.

This is the same trade V8 makes with hidden classes, and the same one Swift 6 makes with isolation. Predictable code is faster code, not by coincidence but because predictability is the precondition for optimisation.

The compiler just made the boundary visible. Most bailouts are the tool declining to preserve something you did not mean to write.


Sources: React Compiler v1.0, React Compiler documentation

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.