Skip to main content
React · lesson 12 of 22
React advanced Lesson 12 of 22

The React Compiler

Run babel-plugin-react-compiler on real source, read the auto-memoized output it writes, and measure the re-renders it actually skips versus a bailout.

By SkillByExample · Updated

react compiler memo performance

React Compiler 1.0 is a build step, not a runtime library — it reads a component’s source and rewrites it to cache values and elements across renders, the same effect memo/useMemo/useCallback give by hand. Every example here runs the real compiler, babel-plugin-react-compiler@1.0.0, from a small Node script using @babel/core — no bundler needed to see its output.

Project Setup

Three packages, on top of the Vitest/Testing Library project from earlier lessons: the compiler plugin itself, the Babel core it runs under, and a plugin that only teaches Babel to parse JSX syntax — it doesn’t transform it.

// package.json (devDependencies)
{
  "babel-plugin-react-compiler": "1.0.0",
  "@babel/core": "8.0.9",
  "@babel/plugin-syntax-jsx": "8.0.1"
}

Compiling a Component and Reading the Output

A plain list component, filtered and mapped, with no memoization written by hand.

// src/compiler/ProductListSource.jsx
export function ProductList({ products, query, onRowRender }) {
  const filtered = products.filter((p) => p.name.includes(query));
  return (
    <ul>
      {filtered.map((p) => {
        onRowRender(p.id);
        return <li key={p.id}>{p.name}</li>;
      })}
    </ul>
  );
}
// compile-with-react-compiler.mjs
import { transformFileSync } from "@babel/core";
import { writeFileSync } from "node:fs";

const result = transformFileSync("./src/compiler/ProductListSource.jsx", {
  presets: [],
  plugins: [
    "@babel/plugin-syntax-jsx",
    [
      "babel-plugin-react-compiler",
      {
        logger: {
          logEvent(filename, event) {
            if (event.kind === "CompileSuccess") {
              console.log(`compiled ${event.fnName}: ${event.memoSlots} memo slots, ${event.memoBlocks} memo blocks`);
            }
          },
        },
      },
    ],
  ],
  babelrc: false,
  configFile: false,
});

writeFileSync("./src/compiler/ProductListCompiled.jsx", result.code + "\n");
console.log("--- output ---");
console.log(result.code);
$ node compile-with-react-compiler.mjs
compiled ProductList: 10 memo slots, 4 memo blocks
--- output ---
import { c as _c } from "react/compiler-runtime";
// src/compiler/ProductListSource.jsx
export function ProductList(t0) {
  const $ = _c(10);
  const {
    products,
    query,
    onRowRender
  } = t0;
  let t1;
  if ($[0] !== onRowRender || $[1] !== products || $[2] !== query) {
    let t2;
    if ($[4] !== query) {
      t2 = p => p.name.includes(query);
      $[4] = query;
      $[5] = t2;
    } else {
      t2 = $[5];
    }
    const filtered = products.filter(t2);
    let t3;
    if ($[6] !== onRowRender) {
      t3 = p_0 => {
        onRowRender(p_0.id);
        return <li key={p_0.id}>{p_0.name}</li>;
      };
      $[6] = onRowRender;
      $[7] = t3;
    } else {
      t3 = $[7];
    }
    t1 = filtered.map(t3);
    $[0] = onRowRender;
    $[1] = products;
    $[2] = query;
    $[3] = t1;
  } else {
    t1 = $[3];
  }
  let t2;
  if ($[8] !== t1) {
    t2 = <ul>{t1}</ul>;
    $[8] = t1;
    $[9] = t2;
  } else {
    t2 = $[9];
  }
  return t2;
}

Babel keeps the source file’s own leading comment — // src/compiler/ProductListSource.jsx lands right after the generated import { c as _c } line, because Babel preserves comments by default and that comment was the first line of the real file on disk. $ is a cache array with one slot per value worth remembering — 10 slots here, in 4 guarded blocks. Each block is an if that compares the current render’s inputs (with !==, which matches memo’s Object.is check except for NaN and +0/-0) against what was cached last time: unchanged, it reuses the old value (t1 = $[3]); changed, it recomputes and re-caches. filtered.map(...) only runs again when onRowRender, products, or query actually changed since the last render — automatic useMemo, with no useMemo written anywhere in the source.

Measuring It: Compiled vs. Plain, by a Real Re-render Count

The compiled file above is the exact one imported here — write it to disk once, then import it like any other module and render it for real. onRowRender is wired to a tiny tracker module so its call count is visible in the test output, not just asserted on.

// src/compiler/rowRenderTracker.js
export let rowRenderCalls = 0;

export function trackRowRender(id) {
  rowRenderCalls++;
}

export function resetRowRenderCalls() {
  rowRenderCalls = 0;
}
// src/compiler/CompiledVsPlain.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { ProductList as ProductListPlain } from "./ProductListSource.jsx";
import { ProductList as ProductListCompiled } from "./ProductListCompiled.jsx";
import { trackRowRender, resetRowRenderCalls, rowRenderCalls } from "./rowRenderTracker.js";

const products = [
  { id: 1, name: "Keyboard" },
  { id: 2, name: "Mouse" },
];

function makeParent(ProductListImpl) {
  return function Parent() {
    const [unrelated, setUnrelated] = useState(0);
    return (
      <div>
        <p>Unrelated: {unrelated}</p>
        <button onClick={() => setUnrelated((n) => n + 1)}>Re-render</button>
        <ProductListImpl products={products} query="" onRowRender={trackRowRender} />
      </div>
    );
  };
}

it("without the compiler: every row re-renders on every unrelated parent re-render", async () => {
  resetRowRenderCalls();
  const user = userEvent.setup();
  const Parent = makeParent(ProductListPlain);
  render(<Parent />);
  const callsAfterMount = rowRenderCalls;

  await user.click(screen.getByRole("button", { name: "Re-render" }));
  await user.click(screen.getByRole("button", { name: "Re-render" }));

  console.log(`plain: calls after mount: ${callsAfterMount}, after two re-renders: ${rowRenderCalls}`);
  expect(rowRenderCalls).toBe(callsAfterMount + products.length * 2);
});

it("with the compiler: rows are cached and skipped when products/query don't change", async () => {
  resetRowRenderCalls();
  const user = userEvent.setup();
  const Parent = makeParent(ProductListCompiled);
  render(<Parent />);
  const callsAfterMount = rowRenderCalls;

  await user.click(screen.getByRole("button", { name: "Re-render" }));
  await user.click(screen.getByRole("button", { name: "Re-render" }));

  console.log(`compiled: calls after mount: ${callsAfterMount}, after two re-renders: ${rowRenderCalls}`);
  expect(rowRenderCalls).toBe(callsAfterMount);
});
$ npx vitest run compiler/CompiledVsPlain.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/compiler/CompiledVsPlain.test.tsx > without the compiler: every row re-renders on every unrelated parent re-render
plain: calls after mount: 2, after two re-renders: 6

stdout | src/compiler/CompiledVsPlain.test.tsx > with the compiler: rows are cached and skipped when products/query don't change
compiled: calls after mount: 2, after two re-renders: 2

 ✓ src/compiler/CompiledVsPlain.test.tsx > without the compiler: every row re-renders on every unrelated parent re-render 212ms
 ✓ src/compiler/CompiledVsPlain.test.tsx > with the compiler: rows are cached and skipped when products/query don't change 29ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  06:47:58
   Duration  19.09s (environment 65%, setup 18%, import 13%, transform 2%, tests 1%)

onRowRender is a stable, module-level function, passed straight through as a prop — stable so the compiled version’s $[0] !== onRowRender check stays false across renders, letting the cache actually hold. Two clicks on a button with nothing to do with the product list: the plain version’s call count goes from 2 (the mount, one per row) to 6, a +4 for both rows on both clicks; the compiled version stays at 2 — zero additional calls. Same products, same markup, same two components — only the generated code differs.

When the Compiler Can’t Help: a Real Bailout

The Compiler requires the code it optimizes to be pure — no mutating something outside the function during render. A module-level counter incremented inside a list’s row callback breaks that, and the Compiler says exactly why instead of guessing.

// src/compiler/ProductListPlain.jsx
export let rowRenders = 0;

export function ProductListPlain({ products, query }) {
  const filtered = products.filter((p) => p.name.includes(query));
  return (
    <ul>
      {filtered.map((p) => {
        rowRenders = rowRenders + 1;
        return <li key={p.id}>{p.name}</li>;
      })}
    </ul>
  );
}
// compile-impure.mjs
import { transformFileSync } from "@babel/core";

const result = transformFileSync("./src/compiler/ProductListPlain.jsx", {
  presets: [],
  plugins: [
    "@babel/plugin-syntax-jsx",
    [
      "babel-plugin-react-compiler",
      {
        logger: {
          logEvent(filename, event) {
            if (event.kind === "CompileError") {
              console.log(`bailout: ${event.detail.options.category} — ${event.detail.options.reason}`);
            }
          },
        },
      },
    ],
  ],
  babelrc: false,
  configFile: false,
});

console.log("--- output (unmemoized, compiler skipped this component) ---");
console.log(result.code);
$ node compile-impure.mjs
bailout: Globals — Cannot reassign variables declared outside of the component/hook
--- output (unmemoized, compiler skipped this component) ---
// src/compiler/ProductListPlain.jsx
export let rowRenders = 0;
export function ProductListPlain({
  products,
  query
}) {
  const filtered = products.filter(p => p.name.includes(query));
  return <ul>
      {filtered.map(p_0 => {
      rowRenders = rowRenders + 1;
      return <li key={p_0.id}>{p_0.name}</li>;
    })}
    </ul>;
}

rowRenders = rowRenders + 1 reassigns rowRenders, which the Compiler’s analysis sees is declared outside this function — a module-level global, from this function’s point of view. That’s exactly the Globals category: “Cannot reassign variables declared outside of the component/hook.” Its static analysis has no case for safely memoizing around a reassignment like that, so it emits this diagnostic and leaves the component’s code untouched: no react/compiler-runtime import, no $ cache, nothing — the header comment survives, same as the successful compile, and the <ul>/<li> tags are still plain JSX in the output, not rewritten to React.createElement calls, because only @babel/plugin-syntax-jsx ran here and that plugin exists purely to let Babel parse JSX; it performs no transform at all. The component still works; it simply gets none of the automatic memoization the pure version did.

Practice

1. Compile a small, unrelated pure component and compare its memo slot/block counts to ProductList’s.

Solution
// src/compiler/practice/Price.jsx
export function Price({ amount, currency }) {
  const formatted = `${currency} ${amount.toFixed(2)}`;
  return <span>{formatted}</span>;
}
// compile-practice1.mjs
import { transformFileSync } from "@babel/core";

const result = transformFileSync("./src/compiler/practice/Price.jsx", {
  presets: [],
  plugins: [
    "@babel/plugin-syntax-jsx",
    [
      "babel-plugin-react-compiler",
      {
        logger: {
          logEvent(filename, event) {
            if (event.kind === "CompileSuccess") {
              console.log(`compiled ${event.fnName}: ${event.memoSlots} memo slots, ${event.memoBlocks} memo blocks`);
            }
          },
        },
      },
    ],
  ],
  babelrc: false,
  configFile: false,
});

console.log("--- output ---");
console.log(result.code);
$ node compile-practice1.mjs
compiled Price: 4 memo slots, 2 memo blocks
--- output ---
import { c as _c } from "react/compiler-runtime";
// src/compiler/practice/Price.jsx
export function Price(t0) {
  const $ = _c(4);
  const {
    amount,
    currency
  } = t0;
  let t1;
  if ($[0] !== amount) {
    t1 = amount.toFixed(2);
    $[0] = amount;
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  const formatted = `${currency} ${t1}`;
  let t2;
  if ($[2] !== formatted) {
    t2 = <span>{formatted}</span>;
    $[2] = formatted;
    $[3] = t2;
  } else {
    t2 = $[3];
  }
  return t2;
}

Fewer slots than ProductList (4 versus 10) — fewer intermediate values worth caching, since there’s no .filter/.map chain, just one .toFixed call and a template string.

2. Have a component mutate a prop array directly with .push, and check whether the Compiler catches it the way it caught the global counter.

Solution
// src/compiler/practice/TaggedList.jsx
export function TaggedList({ items, extraTag }) {
  items.push(extraTag);
  return (
    <ul>
      {items.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}
// compile-practice2.mjs
import { transformFileSync } from "@babel/core";
import { writeFileSync } from "node:fs";

const result = transformFileSync("./src/compiler/practice/TaggedList.jsx", {
  presets: [],
  plugins: [
    "@babel/plugin-syntax-jsx",
    [
      "babel-plugin-react-compiler",
      {
        logger: {
          logEvent(filename, event) {
            if (event.kind === "CompileSuccess") {
              console.log(`compiled ${event.fnName}: ${event.memoSlots} memo slots, ${event.memoBlocks} memo blocks`);
            } else if (event.kind === "CompileError") {
              console.log(`bailout: ${event.detail.options.category} — ${event.detail.options.reason}`);
            }
          },
        },
      },
    ],
  ],
  babelrc: false,
  configFile: false,
});

writeFileSync("./src/compiler/practice/TaggedList.compiled.jsx", result.code + "\n");
console.log("--- output ---");
console.log(result.code);
$ node compile-practice2.mjs
compiled TaggedList: 4 memo slots, 2 memo blocks
--- output ---
import { c as _c } from "react/compiler-runtime";
// src/compiler/practice/TaggedList.jsx
export function TaggedList(t0) {
  const $ = _c(4);
  const {
    items,
    extraTag
  } = t0;
  items.push(extraTag);
  let t1;
  if ($[0] !== items) {
    t1 = items.map(_temp);
    $[0] = items;
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  let t2;
  if ($[2] !== t1) {
    t2 = <ul>{t1}</ul>;
    $[2] = t1;
    $[3] = t2;
  } else {
    t2 = $[3];
  }
  return t2;
}
function _temp(item) {
  return <li key={item}>{item}</li>;
}

4 memo slots, 2 memo blocks — this compiles cleanly, no bailout at all. items.push(extraTag) sits before the first if, unconditional, so it runs on every single call; .push mutating a prop isn’t the specific pattern the Compiler’s analysis checks for, unlike reassigning a module-level variable.

// src/compiler/practice/TaggedList.test.tsx
import { it, expect } from "vitest";
import { render } from "@testing-library/react";
import { TaggedList } from "./TaggedList.compiled.jsx";

it("the compiler doesn't catch a mutated prop array -- it keeps growing on every re-render", () => {
  const items = ["a", "b"];
  const { rerender, container } = render(<TaggedList items={items} extraTag="new" />);
  console.log("items after first render:", items);
  console.log("<li> count after first render:", container.querySelectorAll("li").length);
  expect(items).toEqual(["a", "b", "new"]);

  rerender(<TaggedList items={items} extraTag="new" />);
  console.log("items after second render:", items);
  console.log("<li> count after second render:", container.querySelectorAll("li").length);
  expect(items).toEqual(["a", "b", "new", "new"]);
});
$ npx vitest run compiler/practice/TaggedList.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/compiler/practice/TaggedList.test.tsx > the compiler doesn't catch a mutated prop array -- it keeps growing on every re-render
items after first render: [ 'a', 'b', 'new' ]
<li> count after first render: 3
items after second render: [ 'a', 'b', 'new', 'new' ]
<li> count after second render: 3

 ✓ src/compiler/practice/TaggedList.test.tsx > the compiler doesn't catch a mutated prop array -- it keeps growing on every re-render 27ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:49:56
   Duration  14.96s (environment 74%, setup 25%, transform 1%)

items grows to 4 entries by the second render (['a', 'b', 'new', 'new']), but the rendered <li> count stays at 3. The compiled code’s items.map(_temp) call is gated on $[0] !== items, an identity check — and items is still the exact same array reference both times, because .push mutates in place rather than replacing it. So the second render’s cache check sees no change, skips re-running .map, and reuses the three-item list from the first render — a real, demonstrated bug the Compiler’s clean compile did nothing to prevent, and arguably worse than no memoization at all, since it’s silent. Compiling without errors is not the same as being safe to memoize.

3. Add a console.log call directly in a component’s render and check whether it survives memoization.

Solution
// src/compiler/practice/LogsInRender.jsx
export function LogsInRender({ name }) {
  console.log("rendering", name);
  return <p>{name}</p>;
}
// compile-practice3.mjs
import { transformFileSync } from "@babel/core";
import { writeFileSync } from "node:fs";

const result = transformFileSync("./src/compiler/practice/LogsInRender.jsx", {
  presets: [],
  plugins: [
    "@babel/plugin-syntax-jsx",
    [
      "babel-plugin-react-compiler",
      {
        logger: {
          logEvent(filename, event) {
            if (event.kind === "CompileSuccess") {
              console.log(`compiled ${event.fnName}: ${event.memoSlots} memo slots, ${event.memoBlocks} memo blocks`);
            } else if (event.kind === "CompileError") {
              console.log(`bailout: ${event.detail.options.category} — ${event.detail.options.reason}`);
            }
          },
        },
      },
    ],
  ],
  babelrc: false,
  configFile: false,
});

writeFileSync("./src/compiler/practice/LogsInRender.compiled.jsx", result.code + "\n");
console.log("--- output ---");
console.log(result.code);
$ node compile-practice3.mjs
compiled LogsInRender: 2 memo slots, 1 memo blocks
--- output ---
import { c as _c } from "react/compiler-runtime";
// src/compiler/practice/LogsInRender.jsx
export function LogsInRender(t0) {
  const $ = _c(2);
  const {
    name
  } = t0;
  console.log("rendering", name);
  let t1;
  if ($[0] !== name) {
    t1 = <p>{name}</p>;
    $[0] = name;
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  return t1;
}

2 memo slots, 1 memo block — this compiles successfully too (unlike the global-counter example), caching only the returned <p> element by name. console.log("rendering", name) sits before that if, unconditional, so it runs on every call regardless of whether the cache hits.

// src/compiler/practice/LogsInRender.test.tsx
import { it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import { LogsInRender } from "./LogsInRender.compiled.jsx";

it("a console.log call isn't memoized away, even though the component compiled successfully", () => {
  const spy = vi.spyOn(console, "log");
  const { rerender } = render(<LogsInRender name="Ada" />);
  rerender(<LogsInRender name="Ada" />);

  const renderLogs = spy.mock.calls.filter((call) => call[0] === "rendering");
  console.log("render log calls captured:", renderLogs.length);
  expect(renderLogs).toHaveLength(2);
  spy.mockRestore();
});
$ npx vitest run compiler/practice/LogsInRender.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/compiler/practice/LogsInRender.test.tsx > a console.log call isn't memoized away, even though the component compiled successfully
rendering Ada
rendering Ada
render log calls captured: 2

 ✓ src/compiler/practice/LogsInRender.test.tsx > a console.log call isn't memoized away, even though the component compiled successfully 24ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:49:02
   Duration  20.26s (environment 74%, setup 24%, transform 1%)

"rendering Ada" prints twice and renderLogs.length is 2 — console.log sits outside the cached block, the same way items.push did in the previous task, so it runs on both renders even though name never changed between them and the <p> element itself was cached.

Frequently Asked Questions

What does the React Compiler actually do?
It's a build-time step (a Babel plugin) that rewrites a component's source to cache intermediate values and the elements it returns, re-using them across renders when their inputs haven't changed. It produces the same effect as hand-written memo/useMemo/useCallback, generated automatically from plain code.
Do I still need memo, useMemo, and useCallback with the Compiler?
For code the Compiler successfully processes, no — it inserts equivalent caching itself. It's still useful to understand them, because the Compiler can decline to optimize a component (a bailout) and leave it exactly as written, unmemoized, with no error — so a component with hand-written memoization still benefits when the Compiler can't help. React's own docs also keep useMemo/useCallback as an escape hatch for one specific case the Compiler doesn't replace: stabilizing a value that's also used as a useEffect dependency, so the effect doesn't re-run on every render.
Why did the Compiler skip (bail out on) one of these examples?
It requires the code it optimizes to be pure, in the same sense the Rules of Hooks and Rules of React require: no reassigning variables declared outside the function during render. The bailout example in this lesson reassigns a module-level counter directly inside render — the Compiler's static analysis specifically flags that as a Globals violation and declines to touch the component.
Does a successful compile guarantee the component is actually pure?
No. The Compiler only detects the specific impurity patterns its static analysis looks for, such as mutating a variable declared outside the function. A mutation it doesn't check for, like calling .push on a prop array, can still compile successfully and run unmodified on every call — a real, demonstrated gap between compiling cleanly and being safe to memoize.