For years the React performance story was the same: profile, find a slow component, slap useMemo or useCallback on it, verify it helped, repeat. Experienced devs got good at this ritual. Junior devs cargo-culted memo() everywhere and created subtle bugs. Everyone wrote more code than the runtime actually needed.
React Compiler — the project the team spent years on under the codename React Forget — is supposed to end this. Released as stable with React 19, it’s a Babel/SWC transform that automatically inserts memoization at build time. No manual useMemo. No React.memo. The compiler figures it out.
The question that nobody answers clearly: how does it actually decide what to memoize, and when does it quietly give up? That’s what this article is about.
Official repo: github.com/facebook/react/tree/main/compiler
What React Compiler Actually Does
The high-level pitch ("automatic memoization") sells the idea but obscures the mechanism. The compiler is not doing magic. It’s running static analysis over your component and hook code to build a dependency graph, then generating the equivalent of what a careful human would write with useMemo and useCallback.
The output is still regular React. No new runtime, no virtual machine, no JIT. The transformed code calls c() — a compiler-specific hook aliased from react/compiler-runtime — which is a stable-sized array used as a cache slot. At runtime it does a simple identity-equality check on dependencies and skips recomputation when nothing changed.
A tiny example makes this concrete. You write:
function ProductCard({ product, onAdd }) {
// This is expensive — let's say it formats pricing with locale logic
const formattedPrice = formatPrice(product.price, product.currency);
return (
<div className="card">
<h2>{product.name}</h2>
<span>{formattedPrice}</span>
{/* onAdd passed to a child — without memo, this causes child re-renders */}
<AddButton onClick={onAdd} label="Add to cart" />
</div>
);
}
The compiler emits something roughly like this (simplified — the real output is uglier but equivalent):
function ProductCard({ product, onAdd }) {
const $ = _c(4); // 4 cache slots
let formattedPrice;
if ($[0] !== product.price || $[1] !== product.currency) {
formattedPrice = formatPrice(product.price, product.currency);
$[0] = product.price;
$[1] = product.currency;
$[2] = formattedPrice;
} else {
formattedPrice = $[2];
}
let t0;
if ($[3] !== onAdd) {
t0 = (
<div className="card">
<h2>{product.name}</h2>
<span>{formattedPrice}</span>
<AddButton onClick={onAdd} label="Add to cart" />
</div>
);
$[3] = onAdd; // note: onAdd identity matters
} else {
t0 = $[3]; // reuse the previous JSX
}
return t0;
}
The compiler split the component into granular reactive units, identified which inputs each unit depends on, and inserted cache checks. You didn’t write a single useMemo. This is the whole premise.
The Analysis Pipeline
To understand failures, you need to know the three phases the compiler runs on each component:
1. HIR construction (High-level Intermediate Representation)
The compiler converts your component’s AST into its own IR that understands React semantics — not just JavaScript. It knows that a function that starts with a capital letter and is called in JSX is a component. It knows that useState returns a tuple where the second element is a stable setter. It knows that hook calls have ordering constraints.
2. Alias and effect analysis
This is where things get interesting. The compiler does a form of escape analysis to determine whether a value "escapes" — gets written into a ref, passed to a non-analyzed function, stored outside the component. If formattedPrice only ever flows into the JSX, it’s safe to memoize. If it gets written into window.lastPrice, the compiler loses confidence.
It also tracks mutability. Arrays and objects passed as props, local arrays created inside render — the compiler reasons about whether any code mutates them, because mutating a memoized value and expecting React to re-render is a bug the compiler actively refuses to create.
3. Memoization planning
Armed with the dependency graph and escape/mutation information, the compiler decides what gets cached and how many cache slots to allocate. It generates the c() calls and the condition checks.
When the Compiler Bails Out
This is the part that matters in production. The compiler has a concept of bail-out: when it encounters a pattern it can’t reason about safely, it skips optimization for that scope — sometimes the whole component, sometimes just one expression.
By default, bail-outs are silent. You ship to prod, the component is still re-rendering on every parent update, and you have no idea.
Enable the eslint plugin to catch these at development time:
npm install --save-dev eslint-plugin-react-compiler
// eslint.config.js
import reactCompiler from 'eslint-plugin-react-compiler';
export default [
{
plugins: { 'react-compiler': reactCompiler },
rules: {
'react-compiler/react-compiler': 'error',
},
},
];
Now let’s go through the real bail-out categories.
Bail-Out 1: Rules of Hooks Violations
The compiler requires valid hooks usage — not just because the runtime does, but because its own analysis depends on hooks being called unconditionally and in order. A conditional hook call breaks the dependency graph.
// COMPILER BAILS ON THIS COMPONENT ENTIRELY
function UserProfile({ isAdmin }) {
if (isAdmin) {
const [adminData] = useState(null); // hooks inside condition
}
return <div />;
}
This is also a runtime bug, so it’s already caught by the linter. But if you have a rules violation anywhere in a component, the compiler skips the entire component — not just the offending branch.
Bail-Out 2: Mutation of Props or Frozen Values
The compiler enforces that props and hook return values are treated as immutable. If you mutate them, it won’t memoize the component because it can’t reason about the resulting state.
// COMPILER REFUSES TO OPTIMIZE
function ItemList({ items }) {
// Direct prop mutation — the compiler sees this and gives up
items.sort((a, b) => a.name.localeCompare(b.name));
return items.map(item => <Item key={item.id} item={item} />);
}
// FIX: copy before mutating
function ItemList({ items }) {
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
return sorted.map(item => <Item key={item.id} item={item} />);
}
Same issue with state:
// BAILS OUT
function Counter() {
const [arr, setArr] = useState([]);
arr.push(1); // mutating state directly — game over for the compiler
return <div>{arr.length}</div>;
}
Bail-Out 3: Captured Refs in Closures
Refs (useRef) are intentionally mutable and exist outside React’s rendering model. The compiler is conservative: if a ref value escapes into a closure that gets optimized, it risks returning a stale cached value. So it often bails out on components that use refs in non-trivial ways.
// The compiler may bail on the optimized callback if it captures ref.current
function VideoPlayer({ src }) {
const playerRef = useRef(null);
// This closure captures ref.current — the compiler is cautious here
const handleSeek = (time) => {
playerRef.current.seek(time);
};
return <Player ref={playerRef} src={src} onSeek={handleSeek} />;
}
In practice the compiler handles common ref patterns fine, but if you’re reading ref.current inside deeply nested derived values, it’ll skip caching that branch.
Bail-Out 4: Dynamic Property Access
Static analysis can’t follow string keys computed at runtime. If you access an object with a dynamic key, the compiler can’t know which property changed.
// PARTIAL BAIL-OUT on the value derived from dynamic access
function ThemeToken({ tokenName }) {
const theme = useTheme();
// The compiler can't track which property changes when tokenName changes
const value = theme[tokenName];
return <span style={{ color: value }}>{tokenName}</span>;
}
This gets skipped or handled conservatively. The fix is to make the access predictable — but honestly, if tokenName is truly dynamic, you may not be losing much; the dependency on tokenName means this recomputes whenever tokenName changes anyway.
Bail-Out 5: Global and Module-Level Mutations
If your component reads a module-level variable that gets mutated, the compiler won’t see the mutation as a React dependency. It’ll memoize based on what it can see (the component’s props/state), which means the UI can get out of sync with the global state.
// Top-level mutable variable — invisible to the compiler
let globalCounter = 0;
function Counter() {
// The compiler sees no React dependencies here, memoizes aggressively
// But globalCounter changes won't trigger re-renders anyway
return <div>{globalCounter}</div>;
}
This is already a broken pattern without the compiler — it just makes the brokenness harder to see.
Bail-Out 6: Non-Standard Iterables and Generators
The compiler has first-class understanding of arrays and objects. Generator functions, custom iterables, and exotic data structures fall outside its analysis. Components that iterate over generators or use non-standard collection types will see partial bail-outs.
function* generateItems(count) {
for (let i = 0; i < count; i++) yield { id: i };
}
function ItemGrid({ count }) {
// The compiler doesn't deeply analyze generator output
const items = [...generateItems(count)];
return items.map(item => <Cell key={item.id} />);
}
Spreading into an array first (as above) often recovers optimization for the downstream JSX, but the spread expression itself won’t be memoized unless the compiler can prove the generator is pure.
Gotchas You’ll Hit in Real Projects
Bail-outs are granular, not binary. A component with one problematic pattern might still get 70% of its sub-expressions memoized. The ESLint plugin reports at the expression level — read the output carefully before assuming nothing is optimized.
Third-party libraries are opaque. When you pass a function from an external library to a hook or event handler, the compiler can’t inspect the library’s internals. It conservatively assumes the function might mutate things. Wrap calls to opaque external functions in your own stable wrappers when the performance matters.
use client in Next.js doesn’t block the compiler. The compiler runs on both server and client components. But it won’t attempt to memoize async Server Components because they’re not called by React’s render loop in the same way — they run once on the server. Don’t expect compiler wins there.
The 'use no memo' escape hatch exists. If a component is intentionally side-effectful in render (rare, but it happens — think canvas drawing, DOM measurement), you can opt it out:
function LiveCanvas({ data }) {
'use no memo'; // compiler won't touch this component
const canvas = useRef(null);
// ...direct DOM manipulation that must run every render
}
Use this sparingly. If you find yourself writing it often, you have an architectural problem, not a compiler problem.
Memoization can hide real bugs. If a component was accidentally depending on referential instability to get fresh data (a sign of a bad design, but it happens), the compiler "fixing" it will break the behavior. Audit components that behave strangely after enabling the compiler — the bug predates the compiler, the compiler just made it visible.
Production Setup
Install and configure:
# React 19+ required
npm install react@19 react-dom@19
# The compiler itself
npm install --save-dev babel-plugin-react-compiler
For Vite:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
['babel-plugin-react-compiler', {
// Start with a specific directory during rollout
// Remove sources restriction once you're confident
sources: (filename) => filename.includes('/src/components/'),
}],
],
},
}),
],
});
For Next.js 15+:
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
experimental: {
reactCompiler: true,
},
};
export default config;
Rollout strategy that won’t bite you: enable the ESLint plugin first, fix all violations, then enable the compiler on a subset of directories. Expand the sources filter over a few deploys rather than flipping it project-wide on day one. The compiler is stable, but your codebase’s cleanliness determines how many surprises you get.
Verifying It’s Actually Working
The React DevTools Profiler shows a "Memo ✓" badge next to components that were successfully memoized by the compiler. This is the fastest way to verify optimization without reading generated code.
For a programmatic check in CI, look at the build output. Babel will emit warnings for components it couldn’t optimize when you pass compilationMode: 'annotation' and mark components with 'use memo':
function MyComponent({ data }) {
'use memo'; // If compiler can't optimize this, it throws a build error
return <div>{data.value}</div>;
}
Use 'use memo' on your most performance-critical components as a compile-time contract. The build will fail loudly if you accidentally introduce a bail-out pattern, rather than silently degrading.
The Honest Assessment
React Compiler eliminates the busywork of performance optimization for the vast majority of components — the ones that are pure functions of their props and state with no funny business. For that case, it does the right thing automatically and you never have to think about it.
It does not replace understanding React’s rendering model. You still need to know why referential equality matters, why object literals in JSX create new instances each render, and why expensive computations need to be tracked. The compiler handles the mechanics, not the architecture. If your component is re-rendering because its parent is passing a new object literal on every render, the compiler will memoize the child — but the parent will still recompute whatever creates that object. You still have to fix the source.
The bail-out surface is real but manageable. Fix your mutations, follow the Rules of Hooks religiously, and keep your component code reasonably pure — things you should be doing anyway. The ESLint plugin surfaces what’s left.
The old useMemo / useCallback scattered through the codebase? Once the compiler is enabled and verified, you can delete most of it. That alone is worth the migration.