Skip to content

The React Compiler

Historically, keeping React fast meant sprinkling memoization by hand: useMemo to cache expensive values, useCallback to keep function identities stable, React.memo to skip re-rendering a component whose props didn’t change. It works, but it’s noisy, easy to get wrong (a missing dependency, a wrong comparison), and clutters otherwise simple code.

The React Compiler is a build-time tool that does this automatically. It analyzes your components and inserts the equivalent memoization for you, so you write plain, direct code and still get the skip-unnecessary-work behavior:

// You write this — no useMemo, no useCallback, no memo:
function ProductList({ products, query }) {
const filtered = products.filter((p) => p.name.includes(query));
return (
<ul>
{filtered.map((p) => <Product key={p.id} product={p} onSelect={() => select(p.id)} />)}
</ul>
);
}
// The compiler memoizes `filtered`, the `onSelect` callbacks, and the elements
// so unrelated state changes don't re-run or re-render this needlessly.

Per the React docs, the compiler automatically applies the equivalent of memo to components, which is why it “reduces the need for manual memoization.”

What this means for the memoization advice

Section titled “What this means for the memoization advice”

The Hooks module covers useMemo/useCallback/memo in depth — and you still need to understand them, because you’ll read older code full of them and you need to know what they do. But going forward, the guidance flips:

  • With the compiler adopted: write straightforward code; don’t reach for useMemo/useCallback by default. Let the compiler decide.
  • Without it (or in libraries that ship un-compiled): manual memoization is still the tool.

So the skill shifts from “where do I add useMemo?” to “is my code written the way the compiler expects?”

The compiler can only safely memoize code that follows the Rules of React — chiefly that render is pure and you don’t mutate props, state, or values you don’t own. If you break those rules (mutate during render, read/write external things in the render body), the compiler can’t guarantee your component is safe to memoize, and may skip it. This is the practical payoff of the purity you learned in Foundations: it’s not just correctness hygiene, it’s what unlocks automatic optimization.

flowchart TB
  before["Before: you add
useMemo / useCallback / memo
by hand (noisy, error-prone)"] --> after["React Compiler
(build time)"]
  after --> result["Plain code +
auto-inserted memoization"]
  rules["Rules of React
(pure render, no mutation)"] --> after
Manual vs compiler memoization

Status: as of this writing the React Compiler is stable, not experimental. React’s own blog announced it plainly:

We’re releasing the compiler’s first stable release today, plus linting and tooling improvements to make adoption easier. — react.dev/blog

npm view babel-plugin-react-compiler version confirms it: the published version is 1.0.0, not a 0.x or -rc tag. Treat it as the default, not an experiment — write compiler-friendly (pure) code now, and lean on it rather than hand-memoizing everything.

The compiler ships as a Babel plugin, and how you wire it into Vite depends on your @vitejs/plugin-react version — this is exactly the kind of detail that goes stale fast, so check yours (npm ls @vitejs/plugin-react) before copying an install snippet from memory. This course’s probe has @vitejs/[email protected], and react.dev is explicit about the cutoff:

In @vitejs/[email protected], the inline Babel option was removed. — react.dev/learn/react-compiler/installation

For 6.0.0 and later, install @rolldown/plugin-babel alongside babel-plugin-react-compiler and use the exported reactCompilerPreset as a separate plugin, not an inline react({ babel: {...} }) option (that option no longer exists — passing it is a silent no-op at best and a type error under strict TypeScript, since Options has no babel field in 6.1.1):

// vite.config.example.ts — @vitejs/plugin-react >= 6.0.0 (illustrative; see prose for why this fence isn't type-checked)
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()] }),
],
})

Pre-6.0.0, the old inline form still applies:

react({
babel: { plugins: ['babel-plugin-react-compiler'] },
})

This lesson doesn’t run npm run build through either wiring (that would need @rolldown/plugin-babel installed in the shared course probe, which this lesson doesn’t own) — but it does run the actual compiler transform beneath both of them, directly through @babel/core + babel-plugin-react-compiler, so the part that matters — what the compiler does to your code — is real, not simulated:

To see what this actually does, here’s [email protected] (via @babel/core, run directly in this course’s probe, not simulated) transforming one plain component:

// input — the ProductList component from earlier in this lesson
function ProductList({ products, query }) {
const filtered = products.filter((p) => p.name.includes(query));
return (
<ul>
{filtered.map((p) => <Product key={p.id} product={p} onSelect={() => select(p.id)} />)}
</ul>
);
}

Real output, byte-for-byte from that run:

import { c as _c } from "react/compiler-runtime";
import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
function ProductList(t0) {
const $ = _c(7);
const {
products,
query
} = t0;
let t1;
if ($[0] !== products || $[1] !== query) {
let t2;
if ($[3] !== query) {
t2 = p => p.name.includes(query);
$[3] = query;
$[4] = t2;
} else {
t2 = $[4];
}
const filtered = products.filter(t2);
t1 = filtered.map(_temp);
$[0] = products;
$[1] = query;
$[2] = t1;
} else {
t1 = $[2];
}
let t2;
if ($[5] !== t1) {
t2 = /*#__PURE__*/_jsxDEV("ul", {
children: t1
}, void 0, false);
$[5] = t1;
$[6] = t2;
} else {
t2 = $[6];
}
return t2;
}
function _temp(p_0) {
return /*#__PURE__*/_jsxDEV(Product, {
product: p_0,
onSelect: () => select(p_0.id)
}, p_0.id, false);
}

_c(7) allocates a 7-slot memo cache from react/compiler-runtime; each $[n] slot is one memoized value or dependency, checked with !== (Object.is-style) before recomputing. This is exactly the “equivalent of useMemo/useCallback/memo” the docs describe — just generated for you, with more fine-grained slots than a human would bother writing by hand.

compilationMode: which functions get compiled

Section titled “compilationMode: which functions get compiled”

By default the compiler doesn’t compile every function in your codebase — it infers which ones are components or hooks. The compilationMode option controls that inference:

  • 'infer' (default) — compiles functions written like components (PascalCase, return JSX) or hooks (use-prefixed, call other hooks), plus anything explicitly marked "use memo".
  • 'annotation' — compiles only functions marked with "use memo". This is the recommended starting point for a large, unfamiliar codebase: nothing changes until you opt a component in.
  • 'syntax' — compiles only components/hooks written with Flow’s component/hook syntax (not relevant to a plain TypeScript codebase).
  • 'all' — compiles every top-level function. Not recommended: it can compile plain helper functions that were never components.
// vite.config.annotation.example.ts — @vitejs/plugin-react >= 6.0.0
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({ compilationMode: 'annotation' })] }),
],
})

Two function-level directives pair with compilationMode for gradual adoption: "use memo" opts a single function in under annotation mode, and "use no memo" opts a function out even under infer mode — an escape hatch for a component you suspect the compiler mishandles, while you fix the underlying issue:

src/ProblematicComponent.tsx
export function ProblematicComponent() {
"use no memo"; // TODO: remove once the Rules of React violation below is fixed
return null;
}

The documented adoption path is: start in annotation mode and mark well-tested components with "use memo"; once confident, switch the whole project to infer mode and use "use no memo" only for the rare holdout.

The compiler is deliberately conservative: per React’s own docs, compiler errors are rare because it’s designed to skip problematic code rather than fail the build. When it can’t prove a function follows the Rules of React, it leaves that function completely untouched rather than risk memoizing something unsafely.

Take the setState-during-render example from Foundations — a real Rules of React violation:

src/BadSetState.tsx
import { useState } from 'react'
// Violates the Rules of React: calls setCount unconditionally during render.
export function BadSetState() {
const [count, setCount] = useState(0)
setCount(count + 1)
return <p>{count}</p>
}

Compiling this with the same [email protected] call as ProductList above produces this — no _c(), no memo cache, the function passed through completely as-is:

import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
function BadSetState() {
const [count, setCount] = useState(0);
setCount(count + 1); // setState during render — Rules of React violation
return /*#__PURE__*/_jsxDEV("p", {
children: count
}, void 0, false);
}

That’s the bail-out in action: silent, safe, and easy to miss if you’re not looking for the missing _c() call.

eslint-plugin-react-hooks v6 ships the compiler’s own diagnostics as lint rules — recommended-latest turns on rule-of-hooks and the newer compiler-specific rules (set-state-in-render, immutability, purity, set-state-in-effect, and more), so you get the same bail-out reasons as ESLint errors, at edit time, without adopting the compiler itself. Running ESLint on the exact BadSetState.tsx above ([email protected], recommended-latest config):

/BadSetState.tsx
6:3 error Error: Calling setState during render may trigger an infinite loop
Calling setState during render will trigger another render, and can lead to infinite loops. (https://react.dev/reference/react/useState).
4 | export function BadSetState() {
5 | const [count, setCount] = useState(0)
> 6 | setCount(count + 1)
| ^^^^^^^^ Found setState() in render
7 | return <p>{count}</p>
8 | }
9 | react-hooks/set-state-in-render
✖ 1 problem (1 error, 0 warnings)

That’s the same violation the compiler silently bailed out on above — except the lint rule fails your build (or your editor) immediately, with a file and line number, instead of quietly skipping optimization.

The compiler is not “smarter useMemo” — it’s a static analysis pass that either can prove your function is safe to memoize, or leaves it completely alone. There is no partial credit: a function either gets the full _c() memo-cache treatment, or it passes through byte-for-byte as you wrote it. That binary outcome is why the eslint rules matter as much as the compiler itself — they’re the only way to find out which of the two you got, before you go looking for a missing performance win that never happened.

ChoiceWhat it gives youCost
compilationMode: 'infer' (default)Compiles anything that looks like a component/hook, no per-file opt-in neededOn an unfamiliar codebase, you can’t always predict what got compiled without checking output
compilationMode: 'annotation' + "use memo"Nothing changes until you explicitly opt a component in — safest rolloutExtra directive to add and remove per component during migration
"use no memo" escape hatchLets you ship today around a component the compiler mishandlesSilent by design — nothing reminds you to remove it once the underlying issue is fixed
eslint-plugin-react-hooks@6 recommended-latestCatches the same bail-out reasons as real lint errors, without adopting the compilerIts rule set (immutability, purity, set-state-in-render, …) is stricter than plain rules-of-hooks — expect new errors on existing code the first time you turn it on
  • “If the compiler can’t memoize my component, the build fails.” It doesn’t — per React’s own docs, compiler errors are rare because it’s designed to skip problematic code rather than fail the build. A skipped component looks identical to an un-compiled one; nothing in the build output flags it unless you diff the transformed code or run the eslint rules.
  • “useMemo/useCallback are now useless, so I should delete them.” They still work exactly as before — the compiler generates its own equivalent alongside them, it doesn’t remove yours. The guidance is to stop adding new manual memoization, not to rip out working code for no reason.
  • “"use no memo" is just a performance opt-out, so it’s low-risk to add.” It’s a compiler opt-out, not a perf toggle — the whole point of adding it is that you suspect the function breaks the Rules of React (mutation, impurity), and the compiler skipping it doesn’t fix that underlying bug, it just stops the compiler from being blamed for it.
  • Does a generated component mutate a prop, a hook argument, or anything it doesn’t own — directly reassigning a field, calling .sort()/.push() on an array it was handed, and so on? That’s exactly what makes the compiler bail out silently, with no build error to flag it.
  • If the code includes "use no memo", is there a comment explaining why, and does the reason still apply? A generated snippet that copies this directive without an explanation is hiding a Rules-of-React problem it never actually fixed.
  • Does the code assume useMemo/useCallback are required for correctness (not just performance)? Generated code that reaches for them reflexively on every value, “just in case,” is optimizing for a world without the compiler.
  • Has eslint-plugin-react-hooks actually been run against the generated code with the recommended-latest config — the one that includes the compiler-specific rules — or only against recommended (rules-of-hooks + exhaustive-deps only, which won’t catch a mutation bug)?

🐛 Spot the bug

type Config = { theme: string }
export function Themed({ config }: { config: Config }) {
config.theme = 'dark'
return <p>{config.theme}</p>
}
Show the answer

Themed reassigns config.theme directly — mutating a prop it was handed, which is exactly what the Rules of React forbid (“don’t mutate props, state, or values you don’t own”). Compiling this exact function with [email protected] produces the function completely untouched — no _c(), no memo cache, passed through byte-for-byte, the same silent bail-out shown earlier for setState during render. [email protected] (recommended-latest) catches it immediately: Error: This value cannot be modified / Modifying component props or hook arguments is not allowed. Consider using a local variable instead. (rule react-hooks/immutability). The fix is to copy before mutating — const themed = { ...config, theme: 'dark' } — so the component reads and returns a value it actually owns.

What does the React Compiler do?
With the compiler adopted, what is the guidance on manual memoization?
What must your code follow for the compiler to safely memoize it?
What happens when this is compiled with babel-plugin-react-compiler, and does anything catch it beforehand?
type Config = { theme: string }

export function Themed({ config }: { config: Config }) {
  config.theme = 'dark'
  return <p>{config.theme}</p>
}
Why might this NOT enable the React Compiler on a current @vitejs/plugin-react install?
// vite.config.ts, @vitejs/plugin-react >= 6.0.0
import react from '@vitejs/plugin-react'
export default { plugins: [react({ babel: { plugins: ['babel-plugin-react-compiler'] } })] }
What is the difference between compilationMode: 'annotation' and the default 'infer'?