· react · 16 min read
The React Compiler
The React Compiler memoizes your components at build time, and it does the job better than you can by hand. What it is, how the compiled output works, how to turn it on, and what to do with the useMemo calls already in your codebase.
Neciu Dan
Hi there, it's Dan, a technical co-founder of an ed-tech startup, host of Señors at Scale - a podcast for Senior Engineers, Organizer of ReactJS Barcelona meetup, international speaker and Staff Software Engineer, I'm here to share insights on combining
technology and education to solve real problems.
I write about startup challenges, tech innovations, and the Frontend Development.
Subscribe to join me on this journey of transforming education through technology. Want to discuss
Tech, Frontend or Startup life? Let's connect.
Table of Contents
- What is it
- What the compiled output actually looks like
- How it skips re-renders without React.memo
- How to turn it on
- Which React versions does it work with
- What happens to your existing useMemo and useCallback
- When the compiler skips your component
- What it doesn’t do
- The numbers
- Where this leaves you
- References
Right now, somewhere in your codebase, a component wrapped in React.memo still re-renders on every keystroke.
The wrapper seems to work. The component is memoized, and callbacks are wrapped in useCallback; the author put some effort into it.
But somewhere up the tree, a parent passes an inline arrow function as a prop, or builds a fresh options object during render, or spreads props it received from its own parent.
The check inside memo sees a changed prop and re-renders the component every single time, exactly as if the wrapper wasn’t there.
React never warns you when this happens. The app works, and the tests pass, so the broken optimization just sits there, and the costs add up quietly.
Manual memoization doesn’t scale.
Meta had this problem and developed a compiler to automate memoization, eliminating the need for developers to manually fix components.
The compiler powers Instagram and the Meta Quest Store in production, and it automatically detects dependencies within your components, inserting optimal memoization into the compiled code.
This saves development time, reduces bugs, and ensures the shipped code is always the most efficient version, with zero manual memoization required in your source code.
Say hi to the React Compiler.
What is it
The React Compiler is a build-time tool. It runs during your build and rewrites your components with memoization added.
Your build already rewrites code for you—for instance, by turning JSX into JavaScript. The React Compiler works as a Babel plugin, fitting seamlessly into this process.
And because the optimization happens in the build output, you get to write the version with none of it in your source:
function List({ items, onSelect }) {
const handleClick = (item) => {
onSelect(item.id);
};
return (
<ul>
{items.map(item => (
<Item key={item.id} label={item.label} onClick={() => handleClick(item)} />
))}
</ul>
);
}
There’s no manual memoization anywhere. That means you dont have to add useMemo, useCallback or React.memo everywhere in your components.
The compiler analyzes which values depend on which props and state, then generates code that caches each piece and recomputes it only when its actual inputs change.
This project started at Meta under the codename React Forget, because the goal was to make you forget memoization exists.
A first public demo appeared in 2021, an experimental release followed at React Conf 2024, and version 1.0 landed on October 7, 2025.
Despite the shared timeline with React 19, the compiler is not part of the react package.
Installing React 19 gives you the runtime support the compiled code relies on, but the compilation itself is a separate tool you add to your build pipeline.
What the compiled output actually looks like
Now that we know what the Compiler is and does, once you add it and run it on your codebase, you’d expect the compiler to insert useMemo and useCallback calls for you in the final result.
It doesn’t because useMemo is complicated.
Every call allocates a closure and a fresh dependency array on every render, just to decide whether the cached value can be reused.
When you memoize five values by hand, that overhead is negligible; when a compiler memoizes nearly everything in every component, it adds up.
So the compiler generates its own caching mechanism instead. For example:
function TodoList({ todos, tab }) {
const visibleTodos = filterTodos(todos, tab);
return <ul>{visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>;
}
Compiles to this (simplified for readability):
function TodoList(t0) {
const $ = _c(5); // request 5 cache slots for this component
const { todos, tab } = t0;
let visibleTodos;
if ($[0] !== todos || $[1] !== tab) {
// an input changed, so recompute and store the result
visibleTodos = filterTodos(todos, tab);
$[0] = todos;
$[1] = tab;
$[2] = visibleTodos;
} else {
// inputs are identical, so reuse the cached value
visibleTodos = $[2];
}
// ...the same pattern repeats for the JSX itself
}
Here is what is happening.
First, the t0 parameter is just your props object, renamed and destructured.
The _c(5) call creates an array with five slots attached to the component instance.
On each render, the code checks current and stored inputs. If they match, it reuses the cached output; otherwise, it recomputes and updates values.
The comparison uses reference equality, just as memo and dependency arrays do.
If a parent builds a new todos array on each render, even with identical contents, every comparison downstream fails, and each cached value gets recomputed. The compiler follows the same reference-equality rules React always has.
The generated check is an if statement over a few array reads, which is about as cheap as JavaScript gets, so the compiler can afford to apply it far more densely than you’d ever apply useMemo by hand.
The dependency tracking is also better.
When you write a useMemo, you decide what goes in the dependency array, and you can get it wrong; the compiler derives the dependencies from the actual data flow of the code, so the set of things it compares is exactly the set of things the computation reads.
And the compiler can memoize in places where useMemo is illegal.
Hooks can’t be called conditionally or after an early return, so a component like this can’t be memoized by hand at all:
function FriendList({ friends }) {
if (friends.length === 0) {
return <NoFriends />; // early return, no hooks allowed past this point
}
const summary = buildSummary(friends); // useMemo can't help you here
return <Summary data={summary} />;
}
The compiler doesn’t care, because its cache slots aren’t hooks in your code. It memoizes each branch independently, including the <NoFriends /> JSX inside the early return.
If you want to see this on your own components, paste them into the React Compiler Playground.
How it skips re-renders without React.memo
The walkthrough above shows values being cached, but caching a filtered array doesn’t stop a child component from re-rendering.
The compiler skips re-renders through a different mechanism.
Let’s start with what JSX actually is. <Item label={label} onClick={handleClick} /> is a function call that produces an element object, a plain description of what should be rendered.
Every render of the parent normally produces a fresh element object for each child.
React has a long-standing bailout rule for these objects: if a component receives the exact same element object it received last time, React skips re-rendering it, because an identical description can’t produce different output.
It’s the same reason the pattern of passing components as children avoids re-renders.
And the compiler exploits that rule systematically.
The JSX itself goes into cache slots, so when nothing an element depends on has changed, the parent’s compiled code hands React the identical element object from the previous render, and React bails out of that entire subtree.
No memo wrapper anywhere; the parent just stops producing new descriptions.
That places the skipping in the parent’s compiled output, not in the child. If you compile a leaf component but its parent stays uncompiled, the parent keeps creating fresh element objects on every render, and your compiled leaf keeps re-rendering exactly as before.
How to turn it on
The compiler is a Babel plugin, so setup depends on your build tool and it must run first in the Babel plugin pipeline, because it needs your original, untransformed source to analyze.
Vite
For Vite, install the plugin along with a small Babel bridge:
bashnpm install --save-dev --save-exact babel-plugin-react-compiler@latest
npm install --save-dev @rolldown/plugin-babel
js// vite.config.js
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()],
}),
],
});
If you’re on a version of @vitejs/plugin-react older than 6.0, there’s a shorter form: the plugin used to accept Babel plugins inline, as react({ babel: { plugins: ['babel-plugin-react-compiler'] } }).
Version 6.0 removed that inline option, which is why the current setup goes through a separate Babel plugin.
Next.js
For Next.js, compiler support went stable in version 16, and enabling it is a single config flag:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;
But know that Next.js normally doesn’t use Babel at all.
It compiles with SWC, a Rust-based tool the Next.js team adopted precisely because it’s much faster than Babel, and the React Compiler only runs through Babel today (to verify this).
So, enabling the compiler reattaches Babel to a pipeline built to avoid it, and your dev and production builds get slower.
Next.js softens this with a pre-pass that detects which files actually contain JSX or hooks and only runs the compiler on those.
An official SWC plugin for the compiler is in progress.
Expo
The React team has said plainly that they expect the compiler to become the default way React code is built, and the ecosystem is rolling toward that in stages.
Expo went first. New apps created with SDK 54 or later ship with the compiler enabled in the default template, so every new Expo app is compiled from the first npx expo start. You can press J in the Expo CLI to open the components panel and see which components were memoized.
Which React versions does it work with
The compiled output needs that _c cache hook at runtime, and the hook ships inside React 19.
So by default, the compiler targets React 19.
You’re not locked out on older versions, though. The compiler officially supports React 17 and 18 through a small runtime package that you install as a regular dependency:
npm install react-compiler-runtime@latest
Then you tell the compiler which React version to generate code for:
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', { target: '18' }], // '17' | '18' | '19'
],
};
The mechanism behind the target option is simple. With target: '19', the generated code imports the cache hook from react/compiler-runtime, which is built into React itself.
With target: '17' or '18', it imports from the standalone react-compiler-runtime package instead, which provides an implementation of the same hook built on APIs that exist in your React version.
So a team stuck on React 18 because of one dependency they can’t upgrade still gets automatic memoization today.
When they eventually move to 19, they change the target, drop the runtime package, and the output switches to the built-in implementation.
The standalone runtime is a polyfill, though, and it behaves slightly differently from React 19’s built-in implementation. It may not retain cached values in all the same circumstances, so treat it as a bridge to React 19.
Class components are a no-no though. The compiler only optimizes function components and hooks, so class components pass through the build untouched; they keep working, but they just gain nothing.
What happens to your existing useMemo and useCallback
Everyone asks this.
First thing first for new code: stop writing them, and stop wrapping components in React.memo while you’re at it.
The compiler’s memoization covers what all three APIs did.
For existing code: leave it alone, at least initially.
You might be inspired to create a giant pull request deleting every useMemo in the codebase, and while this might be satisfing the React docs specifically recommend against it.
If a memoized value is in a useEffect dependency array, its reference stability determines when that effect fires.
Removing the useMemo changes the compilation output, and if the compiler draws its memoization boundaries slightly differently than you did in that spot, the effect can start firing more or less often than before.
The same risk exists in the other direction, on the day you first enable the compiler. An effect whose dependency used to be a fresh object on every render was re-firing on every render, and if the compiler stabilizes that reference, the effect suddenly stops re-firing.
If some behavior quietly depended on that constant re-firing, it stops too.
This is also the real justification behind the --save-exact flag we used earlier. Memoization boundaries can shift between compiler versions, so you want end-to-end tests standing between a compiler upgrade and production.
Your existing hooks don’t get silently discarded either. The compiler only compiles a component when its own inference matches or exceeds the memoization you wrote by hand, and when it can’t prove that, it skips the component entirely rather than ship weaker memoization than you asked for (the preserve-manual-memoization lint rule flags exactly these cases).
The redundant hooks that do get compiled cost almost nothing at runtime, so there’s no urgency; you can clean them up gradually, with tests, once the compiler has been running for a while.
Some manual memoization you’ll keep forever, as an escape hatch.
Take the code below and imagine it without the useMemo. The options object gets created fresh on every render, so the effect’s dependency changes on every render, so the effect tears down and re-runs on every render, and with a real subscription behind it, that means disconnecting and reconnecting every time the user types a character:
// The effect must only re-run when the query meaningfully changes,
// so we pin the reference by hand rather than relying on the
// compiler's boundaries, which may shift between versions.
const options = useMemo(() => ({ query, page }), [query, page]);
useEffect(function syncSearchSubscription() {
const sub = subscribeToSearch(options);
return () => sub.unsubscribe();
}, [options]);
The other case is third-party code that holds references to your functions. A chart library that registers an onClick handler once at initialization, a DOM API, a non-React SDK; those systems live outside the compiler’s view, so when reference identity is part of a contract with the outside world, keep the useCallback.
React 19.2 shrinks even this category further with useEffectEvent, a hook for the case where an effect reads a value but shouldn’t re-run when that value changes.
When the compiler skips your component
The compiler can only memoize code it can prove is safe to memoize, and the proof depends on your code following the Rules of React:
- components stay pure during render
- props and state don’t get mutated
- hooks get called unconditionally.
When a component breaks those rules in a way the compiler can detect, it doesn’t try to repair your code, and it doesn’t break it either.
It skips the component entirely and leaves it exactly as you wrote it.
You can see which components made it through in the Components tab of React DevTools, where compiled components get a small “Memo ✨” badge.
A component missing the badge is a component the compiler gave up on.
That usually means a rule violation, though it can also mean manual memoization; the compiler couldn’t prove it matches.
The lint rules from eslint-plugin-react-hooks will tell you which one you’re looking at.
The badge works in development because the compiler runs wherever the Babel plugin runs, including your dev server. So the compiled behavior is what you’re already profiling locally, rather than something that only appears in a production build.
Before enabling anything, you can also run npx react-compiler-healthcheck on your project.
It scans the codebase and reports how many components can be compiled successfully.
If you enable the compiler globally and one component misbehaves, you opt that one out while you investigate:
function LegacyDataGrid() {
'use no memo';
// skipped by the compiler entirely
}
If you’d rather migrate component by component, you set compilationMode: 'annotation' in the compiler config and opt components in instead:
function ProductList() {
'use memo';
// only annotated functions get compiled
}
For production rollouts of apps with real traffic, there’s a third option.
The compiler’s gating config makes it emit two versions of each compiled function, the optimized one and the original, and switch between them at runtime based on a feature flag function you provide.
That turns the compiler into something you can canary: ship both versions, enable the flag for 5% of users, watch your error rates and metrics, and ramp up as you gain more confidence.
What it doesn’t do
A few boundaries to keep in mind:
The cache is per component. If five components each call processHugeDataset(data) with the same data, that function runs five times, once per component, each result cached separately.
The compiler will not hoist shared work into a shared cache, so cross-component caching is still your job, whether that means lifting the computation up, putting it in context, or letting TanStack Query hold it.
It only compiles your code. Everything in node_modules passes through untouched, because the compiler needs original source and your dependencies ship transformed output.
If a library wants to ship compiled components, the library author has to run the compiler before publishing.
It can’t stabilize what it can’t see. If a third-party hook returns a fresh object on every call, the compiler receives an unstable reference, and you already know from the cache mechanism what happens next: the reference check fails on every render, and every cached value depending on it recomputes.
Your components get optimized, but instability flowing in from outside them does not.
Server Components gain almost nothing. A Server Component renders on the server and never re-renders on the client, and memoization is a tool for skipping re-renders, so there’s nothing there for the compiler to speed up.
It does nothing for the problems that dominate most slow apps. Network waterfalls, oversized bundles, slow servers, fetching in effects: the compiler makes re-renders cheaper, and if your app is slow for any other reason, it will be exactly as slow with the compiler on.
It does not work with Class Components. It skips them, like I mentioned before.
The numbers
Meta shipped the compiler on the Meta Quest Store and measured initial loads and cross-page navigations improving by up to 12%, with certain interactions more than 2.5x faster and memory usage staying neutral.
Sanity precompiled their Studio packages and reported a 20-30% reduction in overall render time and latency, with 1,231 of their 1,411 components compiling successfully on the first pass.
Wakelet rolled the compiler out to 100% of users and measured two Web Vitals:
- LCP, the time until the main content is visible on screen, improved by about 10% (2.6s down to 2.4s).
- INP, the gap between a user interaction and the screen’s response, improved by about 15% (275ms down to 240ms), and on pure-React surfaces like dropdowns, the interaction speedup was closer to 30%.
Your numbers will depend on how much unnecessary re-rendering your app was doing in the first place.
Where this leaves you
For a new project, enable the compiler on day one and write components without thinking about memoization.
Expo already made that decision for you, and in Vite or Next.js, it’s just one config block.
For an existing project, run the healthcheck, upgrade eslint-plugin-react-hooks to v6 or later, and fix what the linter flags.
Then enable the compiler on whole subtrees rather than individual components, profile an interactive page before and after, and leave your existing useMemo calls in place until you can remove them slowly, with tests.
In React 19.2, the profiling part got easier too: React now ships Performance Tracks directly in the Chrome DevTools profiler, with a Scheduler track and a Components track that show exactly which components rendered and when.
Turn it on.
References
- React Compiler v1.0 - React blog, the stable release announcement
- React Compiler: Introduction - React docs
- Introducing React Compiler - React Working Group, on how the compiled output works
- Using the compiler on < React 19 - React Working Group, on the
_ccache hook and the runtime package - React Compiler Playground - paste your components, see the output
- Next.js: reactCompiler - Next.js docs
- Next.js 16 - stable compiler support announcement
- Expo: React Compiler - Expo docs, enabled by default in SDK 54+
- React Compiler Beta Release - React blog, where React 17/18 support landed
- React Compiler: Incremental Adoption - React docs, directives, annotation mode, and runtime gating
- preserve-manual-memoization - React docs, how the compiler treats existing useMemo/useCallback as a floor
- Meta’s React Compiler 1.0 Brings Automatic Memoization to Production - InfoQ, with the Sanity and Wakelet case study numbers
- I tried React Compiler today, and guess what… - Nadia Makarevich, on the compiler’s limits with unstable third-party references
Discover more from The Neciu Dan Newsletter
A weekly column on Tech & Education, startup building and occasional hot takes.
Over 1,000 subscribers