React Compiler
26. July, 2025 • 11 min read • Develop
Memoisation you no longer write by hand
Two years ago I wrote a post about how useCallback does nothing in most of the places people put it. The React Compiler is the React team's answer to the same problem, arrived at from the other direction: instead of teaching everyone when memoisation pays off, take the decision away from us and let a build step make it, every time, everywhere.
It has been called React Forget, then an experimental release, then a beta, and since April it has been a release candidate. It is not finished, but it is well past the stage where trying it is an adventure. Meta runs it in production on Facebook, Instagram, Threads and the Quest Store, and the version you can install today is close to what will ship as 1.0.
The problem it is solving
React’s model is that a state change re-renders a component and everything below it. That simplicity is why React is pleasant to work with and also why it does redundant work: a child whose props are identical still re-renders, and rebuilds its own children while it is at it.
The manual fix has been three APIs. useMemo caches a computed value, useCallback keeps a function reference stable between renders, and React.memo skips a re-render when props compare equal. They work. They are also a tax that gets paid on every component you write, in attention rather than milliseconds. You have to decide what to wrap, keep the dependency arrays honest, and remember that memoising the callback accomplishes nothing unless the component receiving it is also memoised. I went through those trade-offs in detail in Optimising React Components and the conclusion was uncomfortable: most useCallback calls in most codebases are decoration.
Meta measured this before building the compiler. Only about 8% of their React pull requests used manual memoisation at all, and those pull requests took 31 to 46% longer to author. That is a real cost for an optimisation most people get partly wrong. A missing dependency in useMemo produces stale data, which surfaces as a bug that reproduces once a week. Too much memoisation burns memory and cache-comparison time to avoid work that was cheap anyway.
What it actually does
The compiler is a Babel plugin. It reads your source before anything else has touched it, converts each component into an internal representation built around a control flow graph, works out which values depend on which, and emits the memoisation you would have written if you had infinite patience.
Take a component with nothing interesting in it:
// Your source code
export default function Greeting({ name }: { name: string }) {
const message = `Hello, ${name}!`;
return <div>{message}</div>;
}Here is roughly what comes out the other side:
// Compiled output (simplified)
import { c as _c } from "react/compiler-runtime";
export default function Greeting({ name }: { name: string }) {
const $ = _c(2);
let t0;
if ($[0] !== name) {
const message = `Hello, ${name}!`;
t0 = <div>{message}</div>;
$[0] = name;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}$ is a cache slot array sized to the number of values worth caching. First render fills it, subsequent renders compare and reuse. It is the same idea as useMemo, applied to every intermediate value and every JSX element rather than the two or three you happened to worry about.
The interesting part is what it can do that you cannot. Hooks must be called unconditionally at the top level, so anything after an early return is off-limits to useMemo by construction. The compiler has no such restriction, because the cache is an array it indexes rather than a hook call it has to keep in order. It also tracks mutation across a function body properly, which is the bit people get wrong when they hand-write dependency arrays.
Getting it running
Install the plugin, pinned:
npm install --save-dev --save-exact babel-plugin-react-compiler@rc--save-exact is not paranoia. The memoisation the compiler emits changes between versions, and a floating range means two developers on the same branch can produce different output from identical source.
For Next.js the framework does the wiring:
// next.config.js
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
module.exports = nextConfig;You still need babel-plugin-react-compiler installed; the flag only tells Next.js to use it. On recent versions Next.js runs an SWC pass first to work out which files contain components at all, so Babel only touches the files that need it. That matters more than it sounds, because adding a Babel step to an otherwise SWC-based build is the single biggest cost of adoption.
For Vite, hang it off the React plugin’s Babel config:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
],
});And for anything else with a Babel config, the plugin goes first in the list. Not somewhere in the list. First:
// babel.config.js
module.exports = {
plugins: [
'babel-plugin-react-compiler', // must be first
// ... other plugins
],
};The reason is that the compiler needs to see your code as you wrote it. Once another transform has rewritten your JSX or shuffled your arrow functions, the analysis it relies on stops being trustworthy, and rather than guess it will bail out and leave the component alone.
If you are not on React 19
This is the part I care about most, because plenty of code out there is not on 19 and will not be soon. This blog, for instance, still runs Gatsby 4 on React 17, and Gatsby 4 is not moving.
The compiler targets React 19 by default, but it will compile for 17 and 18 if you give it the runtime polyfill and tell it what to aim at:
npm install react-compiler-runtime@rc// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', { target: '18' }],
],
};The polyfill supplies the cache primitive that React 19 has built in. It is not a compatibility hack bolted on late; backwards compatibility was one of the things the release candidate specifically added, which suggests the team understands that “upgrade to 19 first” is not advice most teams can act on this quarter.
The rules it depends on
The compiler is only correct if your components follow the Rules of React. These are not new rules and the compiler did not invent them. What is new is that breaking them used to produce a subtle bug and now produces a subtle bug that is harder to find, because the compiler made a caching decision based on a promise your code did not keep.
Components must be pure. Same input, same output, no side effects on the render path.
// Bad: side effect during render
function Counter({ count }: { count: number }) {
document.title = `Count: ${count}`; // side effect in render!
return <div>{count}</div>;
}
// Good: side effect in useEffect
function Counter({ count }: { count: number }) {
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return <div>{count}</div>;
}Props and state are immutable. The compiler decides a cached value is still good by comparing references. Mutate the thing behind the reference and the comparison lies to it:
// Bad: mutating state directly
function TodoList({ todos }: { todos: Todo[] }) {
const sorted = todos.sort((a, b) => a.name.localeCompare(b.name)); // mutates!
return <ul>{sorted.map(t => <li key={t.id}>{t.name}</li>)}</ul>;
}
// Good: create a new array
function TodoList({ todos }: { todos: Todo[] }) {
const sorted = [...todos].sort((a, b) => a.name.localeCompare(b.name));
return <ul>{sorted.map(t => <li key={t.id}>{t.name}</li>)}</ul>;
}Array.prototype.sort sorting in place is the classic one. reverse and splice are the same trap wearing different hats.
Hooks stay at the top level. Unchanged, but now enforced by something that will act on the assumption.
The lint rule is the actual advice
This is the bit I would do first, and it does not require installing the compiler at all. The compiler’s static analysis has been packaged as a lint rule inside eslint-plugin-react-hooks:
npm install --save-dev eslint-plugin-react-hooks@rc// eslint.config.js
import * as reactHooks from 'eslint-plugin-react-hooks';
export default [
reactHooks.configs['recommended-latest'],
{
rules: {
'react-hooks/react-compiler': 'error',
},
},
];The rule is not on by default in the release candidate, so turn it on explicitly. What it reports is the set of places where the compiler would have to give up: state set during render, refs read where they should not be, mutation of something that came in as a prop. Every one of those is a latent bug today, compiler or no compiler. Fixing them is worth doing on its own terms, and it happens to leave your codebase ready.
Adopting it in pieces
Nobody sensible turns this on across a large repository in one commit. There are two supported ways to go slowly.
Scope it to directories with Babel’s overrides, then widen:
// babel.config.js
module.exports = {
plugins: [
// other plugins...
],
overrides: [
{
test: ['./src/features/dashboard/**'],
plugins: ['babel-plugin-react-compiler'],
},
],
};Or flip it around and compile nothing except what you have marked:
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', { compilationMode: 'annotation' }],
],
};function OptimizedComponent() {
"use memo";
// This component WILL be compiled
return <ExpensiveTree />;
}
function RegularComponent() {
// This component will NOT be compiled
return <SimpleTree />;
}Annotation mode is the one I would start with on an older codebase, because it inverts the risk. Directory mode compiles whatever happens to live in that folder, including the two components someone wrote in a hurry in 2022.
Going the other way, "use no memo" excludes a single component:
function ProblematicComponent() {
"use no memo";
// This component will be skipped by the compiler
return <div>...</div>;
}Treat that directive as a comment with a deadline attached, not a fix. It marks a component that is breaking a rule, and the rule was already being broken before the compiler noticed.
What Meta got out of it
The published figures come from the Quest Store: up to 12% faster initial loads and cross-page navigations, some interactions more than 2.5x faster, and memory usage flat. Flat memory is the number I find most reassuring, since caching everything is exactly the sort of change that trades one resource for another without telling you.
The number that says more about adoption risk is a different one: Meta’s monorepo holds over 100,000 React components, and onboarding them needed few code changes. That is the strongest available evidence that ordinary, non-heroic React code is already compatible.
When it goes wrong
React DevTools puts a sparkle badge next to compiled components in the tree, so you can at least see what you are dealing with. From there the loop is:
- Add
"use no memo"to the component you suspect. - See whether the symptom disappears.
- If it does, the component is breaking a rule somewhere.
- Run the lint rule and read what it says about that file.
- Fix the cause, then delete the directive.
Nearly every real report I have seen traces back to code that depended on referential inequality for correctness. An effect that re-runs because a callback is a new object every render, for instance, works entirely by accident. Give it a stable reference and the effect stops firing, and the bug looks like the compiler broke your component when it only revealed what was holding it together.
You do not have to strip out existing useMemo and useCallback calls first. They keep working. The team’s advice is to leave them where they are, because removing them changes what the compiler chooses to cache, and doing that at the same time as adopting the compiler leaves you with two variables and one bug.
Where this leaves things
Some caveats, none of them dealbreakers. The plugin has to run on original source, which means adding Babel to a build that may have been happily Babel-free. Library code has to be compiled by the library’s own author, so nothing you install benefits until they ship it. The SWC path is still experimental. And a release candidate is a release candidate, however many Meta apps are running it.
Still, my honest reaction is relief rather than excitement. This is not a new capability, it is the removal of a chore, and specifically a chore that produced more bad advice than good code. Whole blog posts, mine included, exist to explain when useCallback is worth it. I would rather they stopped needing to exist.
I will not be putting this near the site you are reading, because Gatsby 4 and React 17 have their own opinions. The lint rule, though, goes on everything I touch from here 🧹
‘Till next time!