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.
npm install -D babel-plugin-react-compiler @babel/core @rolldown/plugin-babel02Turn 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.
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.
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.
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.
function LegacyChart({ data }) {
"use no memo"; // temporary: this component reads a mutable global
return <canvas ref={drawChart(data)} />;
}Try it yourself
- 1
Cache the heading less precisely
In "The compiler's trick, written by hand", make the Price slot check both
nameandamount(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 singlememoon the whole component. - 2
Fix the stale price tag
In the impure example, replace the
settingsobject withconst [currency, setCurrency] = useState("₹")in App and passcurrencyas a prop to both tags. Predict both lines after the click: both now say$250. - 3
Run the lint on your own code
In a local Vite project (Topic 0.4), install
eslint-plugin-react-hooks@latest, add therecommendedconfig and runnpx eslint src. Read each message: every one points to code the compiler would skip.
Code & diagrams
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>
);
}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;
}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
Console
Nothing logged yet.Expected console output
Product rendered
Price rendered 499
--- like (amount unchanged)
Product rendered
--- sale (amount changes)
Product rendered
Price rendered 399The 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
Console
Nothing logged yet.Expected console output
Plain: $250
Memoised: ₹250$ 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.
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-runtimepackage and the compiler'stargetoption ("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
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
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 anduseCallback-like stable functions, more precisely than most hand-written code, and even after an earlyreturnwhere hooks can't go. - 3
How the output works. Compiled components call a tiny hook from
react/compiler-runtime(React 19 ships it; React 17 and 18 need thereact-compiler-runtimepackage) that gives the component a cache array, conventionally named$, stored on its fiber like any hook. The compiled code checksif ($[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
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
Lint rules.
eslint-plugin-react-hooksversion 6 and later includes the compiler's checks as lint rules, enabled in itsrecommendedconfig:react-hooks/purity(noDate.now()orMath.random()in render),react-hooks/refs(noref.currentin 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-memoizationand others. They are worth turning on even without the compiler: every one of them catches a real bug. - 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. WithcompilationMode: "annotation"the compiler does the opposite and compiles only functions that start with"use memo", useful for adopting it gradually. Existingmemo,useMemoanduseCallbackkeep working alongside the compiler.
Explain it without notes
What does the React Compiler do, and when does it run?
Why does the compiler need your code to follow the Rules of React?
What does "use no memo" do and when should you use it?
Why are the compiler-powered lint rules useful even without the compiler?
Practice
Write the Vite config that enables the React Compiler.
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.