React Compiler Beta: What It Auto-Optimizes and When You Still Need Memo
React Compiler beta auto-memoizes components and hooks — but it's not magic. Here's exactly what it handles, where it bails out, and when you still reach for useMemo yourself.
What React Compiler Actually Does
React Compiler — still in beta as of React 19's toolchain — is a build-time transform that reads your component and hook source code and inserts memoization automatically. No useMemo, no useCallback, no React.memo wrappers. It statically analyzes data flow and wraps every value and computation that could be referentially stable in the equivalent of a cached hook call. The output JavaScript is still React. You just don't write the plumbing.
That said, it's not voodoo. The compiler works by enforcing the Rules of React — pure renders, stable identity for the same inputs, no mutations of props or state mid-render — and if your code breaks those rules in any way, the compiler will bail out on that component entirely and leave it un-optimized. Silently. Which is the part that trips people up.
The transform ships via a Babel plugin (babel-plugin-react-compiler) and a Vite plugin, both available since React 19.0. You opt in at the bundler config level, not per file. If you're already on a reasonably modern codebase with correct React patterns, turning it on is genuinely low-risk — most teams report zero breaking changes. If you've got older useEffect-heavy code with escaped mutable refs, you'll see bailout warnings in the compiler's eslint plugin.
Honestly, the most surprising thing about the compiler is how mundane the underlying technique is. It's essentially doing what every experienced React developer already does manually — identify which values change, memoize the rest — but doing it at the AST level with full type-flow awareness. The difference is it never forgets to add a dependency.
The Three Things It Auto-Optimizes
Component re-renders. If a parent re-renders but the props passed to a child haven't changed by reference, the compiler skips the child's render. This is equivalent to wrapping that child in React.memo() — you just don't write it. The compiler identifies this statically and inserts the check at the call site. In practice this eliminates the single most common performance bug in React apps: a top-level state update cascading through an entire tree of components that didn't need to re-run.
Computed values inside renders. Any expression that depends only on stable inputs gets cached across renders. Sort a 5,000-item list? Filter by active status? Build a derived map from a prop array? If the inputs haven't changed, the compiler reuses the previous result without you writing useMemo(() => ..., [dep]). This is roughly the 90% use case for useMemo in typical applications.
Callback identity stability. Functions defined inside a component and passed as props — onClick, onChange, custom handlers — get stable references as long as their closed-over values haven't changed. Same behaviour as useCallback, applied everywhere, automatically. This matters enormously for components that use referential equality to gate prop-change effects, like third-party chart libraries or react-virtualized.
// Before compiler: you'd write this manually
const sortedItems = useMemo(
() => items.slice().sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
const handleSelect = useCallback(
(id: string) => setSelected(id),
[setSelected]
);
// After compiler: just write the logic
const sortedItems = items.slice().sort((a, b) => a.name.localeCompare(b.name));
const handleSelect = (id: string) => setSelected(id);
// Compiler inserts caching automatically — same runtime behaviourWorth noting: the compiler doesn't optimize useEffect dependencies. That's intentional. Effects are side-effectful by definition and the compiler can't safely assume two runs are equivalent, so it leaves your effect dependency arrays exactly as you wrote them. That's your job.
When the Compiler Bails Out
The compiler produces a warning (via eslint-plugin-react-compiler) and skips optimization for any component that violates the Rules of React. The most common bail-out causes you'll hit in a real codebase: mutating props or state directly, reading from mutable refs during render, and calling hooks conditionally. Each of these breaks the static analysis the compiler relies on.
// This component will be skipped — mutating a prop
function BadList({ items }: { items: string[] }) {
items.push('extra'); // mutation! compiler bails here
return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>;
}
// This is fine — immutable transform
function GoodList({ items }: { items: string[] }) {
const withExtra = [...items, 'extra'];
return <ul>{withExtra.map(i => <li key={i}>{i}</li>)}</ul>;
}Mutable refs read during render are a subtler problem. ref.current can change outside the React lifecycle, so the compiler can't safely cache anything that depends on it. If you're reading ref.current in the render path — often to sync scroll position or read a DOM measurement — that component won't be auto-optimized. Move the ref read into a useEffect or useLayoutEffect and you're back in business.
You can also opt a specific component out explicitly with the 'use no memo' directive at the top of the function body. This is useful for components that are intentionally impure — canvas renderers, third-party imperative integrations, anything where you've already decided the component should always re-run. The escape hatch is clean and explicit.
function ImperativeCanvas({ data }: { data: Float32Array }) {
'use no memo'; // compiler skips this component
const ref = useRef<HTMLCanvasElement>(null);
// ... raw canvas draw calls that must run every render
return <canvas ref={ref} />;
}When You Still Write useMemo and useCallback
Here's the honest answer: after the compiler, you'll still reach for manual memoization in a handful of real scenarios. Not often — but the situations exist and you should know them.
Expensive async or external computations that span renders. The compiler optimizes pure synchronous expressions inside render. If you're computing something expensive that involves async results, external caches, or non-React state (say, a WASM-backed physics simulation or a web worker result), you'll still want explicit useMemo with a careful dependency array to control exactly when recalculation happens. The compiler won't touch values that depend on closure-captured async results anyway, since it can't prove stability.
Cross-component shared memoization. The compiler works per-component. If you have an expensive derivation that multiple sibling components need — and you want that derivation computed exactly once — you still need to lift it up and pass it down as a memoized value from a common parent, or use a proper state manager. The compiler doesn't fuse memoization across component boundaries.
Interop with non-React imperative APIs. Chart libraries like Chart.js or D3 (pre-React bindings), maps, video players — they expect stable object references as constructor config. Passing an object literal { color: '#ff0000', radius: 4 } creates a new reference every render. In 2025, many of these libraries added React-aware adapters, but if you're on an older integration, you'll manually useMemo those config objects to prevent spurious re-initialization.
In practice, I'd say the compiler eliminates maybe 85–90% of the useMemo/useCallback boilerplate in a standard CRUD app. The remaining 10% are the genuinely interesting cases where you need to think carefully about identity and timing — which is exactly how it should be. The compiler handles the obvious stuff so your brain is free for the hard problems. Look, that's a good trade.
Enabling the Compiler in Your Project
Setup is a few lines. Install the Babel plugin and the ESLint plugin, then wire them into your config. For a Vite project on React 19:
npm install --save-dev babel-plugin-react-compiler eslint-plugin-react-compiler// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
['babel-plugin-react-compiler', {
// Optional: restrict to specific directories during rollout
// sources: (filename) => filename.includes('/src/components/'),
}],
],
},
}),
],
});For Next.js 15+, the compiler is built into the framework config instead. Add experimental: { reactCompiler: true } to next.config.js and you're done — no Babel plugin needed since Next.js manages its own transform pipeline.
Quick aside: run the ESLint plugin with "react-compiler/react-compiler": "error" in your config before you enable the Babel transform. Fix all the violations first. Going in blind means you'll enable the compiler, see zero performance difference in some components, and spend hours wondering why — when the answer is that 30% of your components bailed out silently. The lint pass surfaces that before it becomes a mystery.
If you're building with Empire UI components — whether that's grabbing something from the glassmorphism components catalogue or using one of the templates — all Empire UI components are written to be compiler-friendly. Pure renders, no prop mutations, stable hook call order. They should auto-optimize without any bail-outs when you enable the compiler.
Profiling Before and After
Don't trust vibes. Profile it. React DevTools 5.x (available since late 2024) has a Compiler badge in the component tree that shows you which components were auto-memoized, which bailed out, and why. Open the Profiler tab, record a user interaction, and look at the flame graph — components the compiler optimized will show 0ms render time on re-renders where props didn't change.
// Useful pattern: measure before enabling compiler
// Use React.Profiler to record baseline render times
<Profiler
id="ProductList"
onRender={(id, phase, actualDuration) => {
if (actualDuration > 16) {
console.warn(`${id} took ${actualDuration.toFixed(1)}ms in ${phase} phase`);
}
}}
>
<ProductList items={items} onSelect={handleSelect} />
</Profiler>A realistic benchmark from a production migration in Q1 2026: a mid-size SaaS dashboard with ~180 components went from 47ms average interaction time to 31ms after enabling the compiler, with no code changes — just the Babel plugin enabled. The gains were mostly from eliminating cascading re-renders in the sidebar and data table. Your numbers will vary, but that 30–35% reduction on interaction latency is a real ballpark for apps that haven't been aggressively manually memoized.
One more thing — the compiler doesn't help with initial render time. It only eliminates *unnecessary* re-renders, so the first paint is unchanged. If your LCP is suffering, that's a code-splitting and bundle-size problem, not a memoization problem. The lighthouse performance audit guide covers initial-load optimizations separately.
Integrating With Heavy UI Libraries Like Empire UI
Component libraries are where the compiler really earns its keep. Every prop you pass to a library component — style objects, callback handlers, config arrays — is a potential source of spurious re-renders if it's not referentially stable. Before the compiler, using a rich component library meant hand-wrapping handlers in useCallback all over your app. Now, the compiler handles that automatically for props your components pass down.
If you're building a performance-sensitive UI — say, an analytics dashboard with live-updating charts and a complex filter sidebar — pair the compiler with Empire UI's ready-made components. The gradient generator and box shadow generator tools generate CSS that you bake in at build time rather than computing in render, which compounds nicely with the compiler's runtime memoization. Static values are always better than memoized ones.
For component-heavy pages — a landing page built from Empire UI blocks, a template with 40+ components rendering simultaneously — the compiler's automatic child-skip behaviour means your hero section or pricing table won't re-render just because your navbar's scroll state changed. That's the kind of thing that makes a page feel instant rather than janky on lower-end devices, and it happens for free once the compiler is on.
The compiler's Beta label will drop once the React team is happy with edge-case coverage, which based on the GitHub milestones looks like sometime in late 2026. But it's already stable enough for production — several large apps including parts of the Meta product surface have been running it for over a year. Don't wait for the stable tag to start evaluating it.
FAQ
For most day-to-day cases, yes — the compiler inserts equivalent caching automatically. You'll still write them manually for expensive cross-component derivations, async-dependent values, and imperative third-party library config that needs exact referential control.
Yes. The sources option in the Babel plugin lets you restrict the transform to specific directories. Run the ESLint plugin first to identify bail-out violations, fix them, then expand the compiler's scope directory by directory.
It skips that component entirely and leaves it as-is — no optimization, no crash, no runtime change. The ESLint plugin (eslint-plugin-react-compiler) surfaces bail-out reasons so you know which components to fix.
Yes — Next.js 15+ supports it natively via experimental: { reactCompiler: true } in next.config.js. No separate Babel plugin needed; the framework handles the transform internally.