
React Compiler Explained: Automatic Memoization Without the Boilerplate
Every React developer has written code like this: a component re-renders too often, so you wrap a computed value in useMemo, wrap a handler in useCallback, wrap a child in memo, and then spend ten minutes getting the dependency arrays right. A month later someone adds an inline object prop and silently breaks the whole chain. Manual memoization works, but it's tedious, easy to get wrong, and clutters components with code that has nothing to do with what they render.
The React Compiler removes most of that work. It's a build-time tool that analyzes your components and hooks and inserts memoization automatically, at a finer level than you'd reasonably write by hand. You keep writing plain components, and the compiled output skips work that doesn't need to happen.
This post explains what the compiler actually does, what the output looks like, how to set it up in Vite and Next.js, how to adopt it gradually in an existing codebase, and which patterns stop it from optimizing your code.
The Problem It Solves
React re-renders a component when its state changes, and by default it re-renders every child too. Usually that's fast enough. It becomes a problem when a child is expensive, or when a re-render causes something downstream to fire, like an effect with an object dependency.
Here's a typical example of manual optimization:
import { memo, useCallback, useMemo, useState } from "react";
type Product = { id: string; name: string; price: number; category: string };
const ProductList = memo(function ProductList({
products,
onSelect,
}: {
products: Product[];
onSelect: (id: string) => void;
}) {
return (
<ul>
{products.map((p) => (
<li key={p.id} onClick={() => onSelect(p.id)}>
{p.name}: ${p.price}
</li>
))}
</ul>
);
});
export function Shop({ products }: { products: Product[] }) {
const [category, setCategory] = useState("all");
const [selected, setSelected] = useState<string | null>(null);
const visible = useMemo(
() => (category === "all" ? products : products.filter((p) => p.category === category)),
[products, category],
);
const handleSelect = useCallback((id: string) => setSelected(id), []);
return (
<>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="books">Books</option>
<option value="games">Games</option>
</select>
<p>Selected: {selected ?? "none"}</p>
<ProductList products={visible} onSelect={handleSelect} />
</>
);
}
All three tools are needed for ProductList to skip re-rendering when only selected changes. Remove any one of them and the optimization silently stops working. If you want the full reasoning behind each, useMemo and useCallback: when memoization actually helps goes deeper.
With the compiler, you write the same component without any of it:
import { useState } from "react";
type Product = { id: string; name: string; price: number; category: string };
function ProductList({
products,
onSelect,
}: {
products: Product[];
onSelect: (id: string) => void;
}) {
return (
<ul>
{products.map((p) => (
<li key={p.id} onClick={() => onSelect(p.id)}>
{p.name}: ${p.price}
</li>
))}
</ul>
);
}
export function Shop({ products }: { products: Product[] }) {
const [category, setCategory] = useState("all");
const [selected, setSelected] = useState<string | null>(null);
const visible =
category === "all" ? products : products.filter((p) => p.category === category);
return (
<>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="books">Books</option>
<option value="games">Games</option>
</select>
<p>Selected: {selected ?? "none"}</p>
<ProductList products={visible} onSelect={(id) => setSelected(id)} />
</>
);
}
After compilation, visible is only recomputed when products or category change, the onSelect function keeps the same identity, and the ProductList element is reused when its inputs haven't changed, so React skips rendering it.
What the Compiler Actually Does
The compiler runs as a Babel plugin during your build. For each component and hook, it builds a model of the data flow: which values depend on which props and state. Then it groups code into reactive scopes and caches each scope's result, recomputing only when its inputs change.
The output is ordinary JavaScript. Simplified, a compiled component looks roughly like this:
import { c as _c } from "react/compiler-runtime";
function Greeting({ name }) {
const $ = _c(2);
let t0;
if ($[0] !== name) {
t0 = <h1>Hello, {name}!</h1>;
$[0] = name;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
_c(2) allocates a small cache array tied to this component instance (it's backed by a hook internally). The compiler stores each input next to the output it produced. On the next render, if name is unchanged, it returns the cached JSX element. When React sees the exact same element object as last time, it skips re-rendering that subtree. That's the same mechanism memo relies on, but applied to individual JSX expressions rather than whole components.
You don't need to read compiled output in daily work, but seeing it once makes the compiler less magical. You can paste code into the React Compiler Playground in the React docs to see the output for any component.
Finer-Grained Than Manual Memoization
Because the compiler works on individual expressions, it can memoize things you'd never bother to:
- JSX elements in the middle of a component, not only the component as a whole.
- Values computed after an early return, where
useMemoisn't allowed because hooks can't be called conditionally. - Inline callbacks and object literals passed as props.
That last point removes a whole category of bugs, like an effect re-running every render because a parent passes an inline options object.
Setting It Up
The compiler is distributed as babel-plugin-react-compiler. It works best with React 19, and also supports React 17 and 18 through an extra runtime package.
Vite
Install the plugin as a dev dependency:
npm install -D babel-plugin-react-compiler@latest
Then add it to the Babel options of @vitejs/plugin-react:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: ["babel-plugin-react-compiler"],
},
}),
],
});
The compiler must run first in the Babel pipeline, because it needs to see your original source. If you have other Babel plugins, list the compiler before them. Vite's React plugin has been evolving alongside Vite's bundler changes, so if the babel option isn't available in your version, check the plugin's README for the current way to enable the compiler. For a general Vite setup, see building React apps with Vite.
Next.js
Next.js has built-in support. Install the plugin and enable it in your config:
npm install -D babel-plugin-react-compiler@latest
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;
Next.js only runs Babel on files that need the compiler, so the rest of the build keeps using its faster SWC pipeline.
React 17 and 18
The compiled output imports from react/compiler-runtime, which only exists in React 19. For older versions, install the runtime polyfill and set the target:
npm install react-compiler-runtime@latest
// babel config
const ReactCompilerConfig = { target: "18" };
module.exports = {
plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]],
};
The Linter: Your Most Important Tool
The compiler assumes your code follows the Rules of React: components are pure, props and state are treated as immutable, and hooks are called unconditionally. When it finds code that breaks these rules, it doesn't crash or warn at build time. It simply skips optimizing that component and leaves it as is.
That's safe, but it means you can't tell which components were optimized without help. The compiler-aware lint rules ship in eslint-plugin-react-hooks. Use a recent version and enable the recommended config:
npm install -D eslint-plugin-react-hooks@latest
// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";
import { defineConfig } from "eslint/config";
export default defineConfig([reactHooks.configs.flat.recommended]);
The rules flag things like mutating props, reading refs during render, and calling setState during render. Fixing them is worthwhile even without the compiler, because they're bugs waiting to happen. Older guides mention a separate eslint-plugin-react-compiler package, which has been folded into eslint-plugin-react-hooks. The fundamentals behind these rules are covered in the rules of hooks explained.
Code the Compiler Can't Optimize
Here are the patterns that most often cause the compiler to bail out.
Mutating Props or State
// Breaks the rules: mutates a prop during render
function SortedList({ items }: { items: string[] }) {
items.sort(); // mutates the parent's array
return <ul>{items.map((i) => <li key={i}>{i}</li>)}</ul>;
}
Fix it by copying first: const sorted = items.toSorted(); (or [...items].sort() in older environments).
Reading Refs During Render
import { useRef } from "react";
function RenderCounter() {
const renders = useRef(0);
renders.current += 1; // reading and writing a ref during render
return <p>Rendered {renders.current} times</p>;
}
Refs are for values that don't affect rendering. If something shows up in the output, it should be state or a derived value. See mastering useRef for where refs belong.
Side Effects in Render
Logging, writing to localStorage, or changing global variables during render make a component impure. Move them into event handlers or effects.
Adopting the Compiler Gradually
For an existing app, turning the compiler on everywhere at once can surface hidden bugs that were masked by extra re-renders. A gradual rollout is safer.
Annotation Mode
Configure the compiler to only compile functions that opt in:
const ReactCompilerConfig = { compilationMode: "annotation" };
Then mark components with the "use memo" directive:
export function Dashboard() {
"use memo";
// ...component body
return <main>Dashboard</main>;
}
Opting Out
With the compiler enabled globally, you can exclude a single component or hook that misbehaves with "use no memo":
export function LegacyChart() {
"use no memo";
// Uses a third-party library that mutates objects in place
return <canvas id="legacy-chart" />;
}
Treat "use no memo" as a temporary escape hatch with a comment explaining why, and come back to fix the underlying issue.
Verifying It Works
React DevTools marks compiled components with a "Memo ✨" badge in the Components tab. Use the Profiler before and after enabling the compiler to compare render counts on a slow interaction. The guide to profiling React apps with React DevTools walks through that workflow.
Should You Remove Existing useMemo and useCallback?
Not in a rush. The compiler works alongside existing memoization, so leaving it in place is harmless. For new code, write without manual memoization and let the compiler handle it.
There are still a few cases where you may keep useMemo or useCallback deliberately: when a value is used as an effect dependency and you need precise control over when the effect fires, or when you're writing a library that must perform well whether or not consumers use the compiler.
Common Mistakes With the React Compiler
- Skipping the linter. Without it, you don't know which components were silently skipped. Turn on the
eslint-plugin-react-hooksrecommended rules first. - Putting the compiler after other Babel plugins. It needs to see the original source, so it must run first.
- Assuming it fixes slow code. The compiler avoids unnecessary re-renders. A component that's slow on every render, like rendering 10,000 rows, still needs virtualization or a better algorithm.
- Relying on extra re-renders. Some code only works because a component happens to re-render often, such as reading a mutable value during render. The compiler can expose these bugs. Fix them rather than opting out permanently.
- Expecting effects to change. The compiler memoizes values, which can make effect dependencies more stable, but it doesn't rewrite your effects. An effect with a missing dependency is still a bug.
Frequently Asked Questions (FAQ) About the React Compiler
Yes. The React team released version 1.0 as stable in late 2025, and it has been used in large production apps at Meta and elsewhere. It's opt-in and enabled through your build tool.
For most cases, yes. The compiler memoizes the JSX passed to children, so a child re-renders only when its inputs change, which is what memo was for. You can keep existing memo calls, but new code usually doesn't need them.
No. It runs at build time as a Babel plugin and outputs regular JavaScript. At runtime, the only addition is a tiny helper from react/compiler-runtime that manages the cache array for each component.
It's designed not to. When it detects code that breaks the Rules of React, it skips that function rather than compiling it incorrectly. Code that breaks the rules in ways it can't detect may behave differently, which is why running the lint rules and testing with a gradual rollout is recommended.
No. The compiler only optimizes function components and hooks. Class components are left untouched and keep working exactly as before.
Slightly. Compiled components contain extra comparison and caching code. In practice the increase is small, and the reduction in wasted renders is usually worth it. Measure on your own app if bundle size is critical.
Conclusion
The React Compiler moves memoization from your source code into the build. It analyzes components and hooks, caches values and JSX at a fine-grained level, and skips work when inputs haven't changed. You write plain components, and you get the benefits of useMemo, useCallback, and memo without the dependency arrays.
To get started, enable the eslint-plugin-react-hooks recommended rules and fix what they report. Then turn on the compiler in your Vite or Next.js config, use annotation mode if your codebase is large, and confirm the results with the "Memo ✨" badges and the Profiler in React DevTools.


