Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced React Compiler 1.0+ ~26 min· topic 5 of 10

Topic 16.5

The React Compiler

In one line

The React Compiler is a build-time Babel plugin that rewrites your components and hooks to cache values, functions and JSX automatically, giving you the benefit of memo, useMemo and useCallback without writing them; it relies on your code following the Rules of React, and you can opt a function out with "use no memo".

Think of it like this

A spell-checker that also tidies your essay. You write normally; before the essay is printed, a careful helper goes through it and adds bookmarks so the reader can skip pages they've already read. You never write the bookmarks yourself. The React Compiler is that helper: you write plain components, and before your app ships it adds the caching you'd otherwise write by hand.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

React Compiler
A build-time tool that adds caching to your components automatically, so you rarely write memo, useMemo or useCallback.
Build time
When your project is turned into files for the browser (npm run build), as opposed to when the app runs.
Babel plugin
An add-on for Babel, a tool that rewrites JavaScript code. The compiler is babel-plugin-react-compiler.
Automatic memoisation
The compiler caching values, functions and JSX for you, recomputing only when their inputs change.
Rules of React
Pure components, no mutation of props or state, hooks at the top level. The compiler depends on them.
Bail out (compiler)
The compiler leaving a component unoptimised because it can't prove optimising it is safe.
Directive
A string at the top of a function, like "use no memo", that gives a tool an instruction.

Step by step

01Install the plugin

Add the compiler as a development dependency. It goes in the build, not the app's runtime code.

terminalwhole filebash
npm install -D babel-plugin-react-compiler @babel/core @rolldown/plugin-babel

02Turn it on in Vite

Vite 8 projects use @vitejs/plugin-react 6, which no longer runs Babel by itself. Add @rolldown/plugin-babel with the reactCompilerPreset() helper that plugin-react exports, as its README shows. Older plugin-react versions (4 and 5) took react({ babel: { plugins: ["babel-plugin-react-compiler"] } }) instead; you'll still see that in many tutorials.

plugin-react 6 also has an experimental native option, react({ compiler: true }), which uses a Rust port of the compiler (oxc-transform-react, installed separately). Prefer the Babel route until it is stable.

vite.config.jswhole filejavascript
import { defineConfig } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";

export default defineConfig({
  plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});

03Or in Next.js 16

Next.js 16 has stable built-in support (it was experimental before). It isn't on by default. Next.js runs the compiler only on files that contain JSX or hooks, to keep builds fast.

next.config.tswhole filetsx
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactCompiler: true,
};

export default nextConfig;

04Turn on the lint rules

Use the recommended config of eslint-plugin-react-hooks (version 6 or later). It reports code the compiler can't optimise, with the reason and a link.

eslint.config.jswhole filejavascript
import reactHooks from "eslint-plugin-react-hooks";

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

05Check what got compiled

React DevTools shows a Memo ✨ badge next to components the compiler optimised. Components without it were skipped: run the linter to see why.

06Opt out a component that misbehaves

If a component behaves differently once compiled (usually because it breaks a rule the compiler couldn't detect), add "use no memo", fix the real cause, then remove the directive.

LegacyChart.jsxwhole filejsx
function LegacyChart({ data }) {
  "use no memo"; // temporary: this component reads a mutable global
  return <canvas ref={drawChart(data)} />;
}
Opt out a component that misbehavesdiagram
Rendering diagram…

Try it yourself

  1. 1

    Cache the heading less precisely

    In "The compiler's trick, written by hand", make the Price slot check both name and amount (if ($[0] !== amount || $[2] !== name)) and click Start sale. Predict whether Price still renders only once per change. Fine-grained slots are why the compiler beats a single memo on the whole component.

  2. 2

    Fix the stale price tag

    In the impure example, replace the settings object with const [currency, setCurrency] = useState("₹") in App and pass currency as a prop to both tags. Predict both lines after the click: both now say $250.

  3. 3

    Run the lint on your own code

    In a local Vite project (Topic 0.4), install eslint-plugin-react-hooks@latest, add the recommended config and run npx eslint src. Read each message: every one points to code the compiler would skip.

Code & diagrams

Before: what you writejsx
function ProductList({ products, onSelect }) {
  const visible = products.filter((p) => p.inStock);
  return (
    <ul>
      {visible.map((p) => (
        <li key={p.id} onClick={() => onSelect(p)}>{p.name}</li>
      ))}
    </ul>
  );
}
After: the shape of the compiler's output (simplified)jsx

Simplified to show the idea. The real output has more slots and different names; paste a component into the React Compiler Playground (playground.react.dev) to see the exact code.

import { c as _c } from "react/compiler-runtime";

function ProductList({ products, onSelect }) {
  const $ = _c(4); // a cache array with 4 slots, kept on this component's fiber
  let t0;
  if ($[0] !== products || $[1] !== onSelect) {
    // Inputs changed: recompute the list and the JSX.
    const visible = products.filter((p) => p.inStock);
    t0 = (
      <ul>
        {visible.map((p) => (
          <li key={p.id} onClick={() => onSelect(p)}>{p.name}</li>
        ))}
      </ul>
    );
    $[0] = products;
    $[1] = onSelect;
    $[2] = t0;
  } else {
    t0 = $[2]; // same inputs: reuse the very same JSX object
  }
  return t0;
}
The compiler's trick, written by hand New tab

Product renders every time App does, but Price is skipped when the amount is the same: Product hands React the same element object, and React skips an element it has already rendered. No memo anywhere. Writing to the cache during render is only safe for a compiler that controls all the code; don't do it in your own components.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Product rendered
  Price rendered 499
--- like (amount unchanged)
Product rendered
--- sale (amount changes)
Product rendered
  Price rendered 399
Why the rules matter: impure code goes stale when memoised New tab

The plain component happens to show the new currency because its parent re-rendered. The memoised one keeps its cached output: its props didn't change. The fix is to put `currency` in state or context, so React (and the compiler) can see it change.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Plain: $250
Memoised: ₹250
Real lint output from eslint-plugin-react-hooks 7.1.1bash
$ npx eslint src/Clock.jsx src/Total.jsx

src/Clock.jsx
  10:15  error  Error: Cannot call impure function during render

`Date.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).

   9 | export function Clock() {
> 10 |   const now = Date.now();
     |               ^^^^^^^^^^ Cannot call impure function
  11 |   return <p>{now}</p>;                       react-hooks/purity

src/Total.jsx
  2:3  error  Error: This value cannot be modified

Modifying component props or hook arguments is not allowed. Consider using a local variable instead.

  1 | export function Total({ cart }) {
> 2 |   cart.total = cart.items.length;
    |   ^^^^ value cannot be modified
  3 |   return <p>{cart.total}</p>;                react-hooks/immutability

✖ 2 problems (2 errors, 0 warnings)

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Read a ref during render

In a compiled project, count renders with const renders = useRef(0); renders.current++; and show {renders.current} in the JSX.

terminal
$ npx eslint src/Renders.jsx
── what you'll see ──
16:3 error Error: Cannot access refs during render
 
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
 
> 16 | renders.current = renders.current + 1;
| ^^^^^^^^^^^^^^^ Cannot update ref during render react-hooks/refs

Myth vs fact

Myth

The React Compiler is part of React and runs in the browser.

Fact

It's a separate build-time Babel plugin. The browser only runs its output, plus the tiny react/compiler-runtime cache hook.

Myth

With the compiler I must delete all my useMemo and useCallback.

Fact

They keep working. The advice is to leave existing ones in place and rely on the compiler for new code, keeping manual memoisation only where you need exact control (for example, effect dependencies).

Myth

The compiler makes rule-breaking code work.

Fact

It skips code it can see breaks the rules, and code that breaks them invisibly can show stale UI. It makes correct code faster; it doesn't fix incorrect code.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The compiler works on a control-flow graph with its own intermediate representation (HIR), infers which values are reactive (depend on props, state or context), groups instructions into reactive scopes, and emits one cache check per scope. That's why it can memoise code after conditionals and early returns, which hand-written hooks can't.

  • ▸

    React 17 and 18 apps can use it with the react-compiler-runtime package and the compiler's target option ("17" or "18"). Libraries can ship precompiled code.

  • ▸

    Rollout strategy for an existing app: enable the lint rules first and fix errors; then enable the compiler with compilationMode: "annotation" or a directory filter; check the Memo ✨ badges in DevTools; watch for behaviour differences (often effects whose dependencies are now stable and stop re-firing); widen gradually.

  • ▸

    Because compiled components return the same element objects for unchanged parts, effects that depended on an unstable object (and fired every render by accident) may stop firing. That's correct behaviour, but it can expose code that relied on the bug.

Remember this

  1. 1

    A compiler here means a tool that reads your source code and outputs different, equivalent code during the build (when Vite or Next.js turns your files into what the browser downloads). The React Compiler is published as babel-plugin-react-compiler, a plugin for Babel (a JavaScript code transformer). It reached version 1.0 in October 2025 and is stable and used in production, including at Meta. It runs at build time only: there is no compiler code in the browser, and it doesn't run in Hectal's live preview, which compiles JSX with sucrase.

  2. 2

    What it does: automatic memoisation. For every component and hook it can understand, the compiler works out which values depend on which props and state, and wraps each group in a cache check. JSX elements, computed values and callbacks are reused while their inputs are the same. That gives you memo-like skipping (a child receives the same element object, so React skips it), useMemo-like caching and useCallback-like stable functions, more precisely than most hand-written code, and even after an early return where hooks can't go.

  3. 3

    How the output works. Compiled components call a tiny hook from react/compiler-runtime (React 19 ships it; React 17 and 18 need the react-compiler-runtime package) that gives the component a cache array, conventionally named $, stored on its fiber like any hook. The compiled code checks if ($[0] !== items) { ...recompute...; $[0] = items; $[1] = result; } else { result = $[1]; }. Empty slots hold a special sentinel value so the first render always computes. The second live example imitates this by hand so you can watch it work.

  4. 4

    The rules your code must follow. The compiler can only memoise safely if your code obeys the Rules of React: components and hooks are pure (same inputs, same output, no side effects during render); props, state and hook return values are never mutated; values aren't changed after being used in JSX; refs aren't read or written during render; hooks are called at the top level. When the compiler finds code that might break these rules, it skips that component (bails out) and leaves it unoptimised rather than risk changing its behaviour. Code that breaks the rules without the compiler noticing (reading a mutable global, as in the third live example) can show stale UI once memoised.

  5. 5

    Lint rules. eslint-plugin-react-hooks version 6 and later includes the compiler's checks as lint rules, enabled in its recommended config: react-hooks/purity (no Date.now() or Math.random() in render), react-hooks/refs (no ref.current in render), react-hooks/immutability (no mutating props or state), react-hooks/globals, react-hooks/set-state-in-render, react-hooks/set-state-in-effect, react-hooks/static-components, react-hooks/preserve-manual-memoization and others. They are worth turning on even without the compiler: every one of them catches a real bug.

  6. 6

    Opting out and opting in. The directive "use no memo" as the first statement of a component or hook tells the compiler to skip it: a temporary escape hatch while you fix a problem, not a permanent setting. With compilationMode: "annotation" the compiler does the opposite and compiles only functions that start with "use memo", useful for adopting it gradually. Existing memo, useMemo and useCallback keep working alongside the compiler.

Explain it without notes

01

What does the React Compiler do, and when does it run?

02

Why does the compiler need your code to follow the Rules of React?

03

What does "use no memo" do and when should you use it?

04

Why are the compiler-powered lint rules useful even without the compiler?

Practice

01

Write the Vite config that enables the React Compiler.

02

This component is skipped by the compiler. Fix it so it can be compiled: function Total({ cart }) { cart.total = cart.items.reduce((s, i) => s + i.price, 0); return <p>{cart.total}</p>; }

Trade-offs

  • ↔

    The compiler removes most hand-written memoisation and is more precise, but adds build time and a dependency on code following the Rules of React; bugs from impure code can become visible when it's turned on.

  • ↔

    Annotation mode makes adoption safe and gradual, at the cost of remembering to add "use memo" where it matters.

Done when you can

  • I can explain what the React Compiler does and that it runs at build time.

  • I can enable it in Vite and in Next.js 16.

  • I can name the Rules of React it relies on.

  • I can read its lint messages and fix the code.

  • I can opt a component out with "use no memo" and explain when that's appropriate.