The React Compiler
What the React Compiler does to your components at build time, how to turn it on, which code it can't optimize, and how to adopt it with directives and lint.
- 01Read what the compiler writesFollow compiled code's memo cache,
_cand$, and explain why it skips more work thanmemoanduseMemoby hand. - 02Turn it onAdd
babel-plugin-react-compilerto a Babel, Vite or Next.js project, and check that it's working. - 03Keep your code compilableFollow the Rules of React with the ESLint plugin's help, opt in or out with
"use memo"and"use no memo", and adopt the compiler step by step.
Memoization the compiler writes for you
In memo, useMemo and useCallback you skipped work by hand: you wrapped components, kept values and functions, and checked that every prop stayed the same. The React Compiler does that for you. It's a build tool from the React team, whose stable 1.0 release came out in October 2025. When you build your app, it reads each component and rewrites it so it keeps what it worked out last time. Here is a tea shop written plainly, with no memo anywhere. The result is live: open the console under it, then type a word in the note box.
import { useState } from "react"; function TeaList({ items }) { console.log("TeaList renders"); return ( <ul> {items.map((tea) => ( <li key={tea}>{tea}</li> ))} </ul> );} export default function App() { const [note, setNote] = useState(""); const [teas, setTeas] = useState(["Green tea", "Black tea"]); return ( <> <input value={note} onChange={(event) => setNote(event.target.value)} placeholder="A note for the shop" /> <button onClick={() => setTeas([...teas, "Mint tea"])} disabled={teas.includes("Mint tea")}> Add Mint tea </button> <TeaList items={teas} /> </> );}TeaList (lines 3 to 12) logs TeaList renders on line 4 each time React calls it. App keeps a note (line 15) and a list of teas (line 16). Line 20's button adds Mint tea, and turns off once it's there. Line 23 renders TeaList with the teas. Type a word: the count beside TeaList renders goes up by 2 with every key (StrictMode renders twice in development, and the console counts a repeated line instead of printing it again). Each key renders App, and App renders TeaList.
Now the same file after the React Compiler, exactly as babel-plugin-react-compiler 1.0.0 writes it. This course's live examples don't run the compiler, so the compiled code was made ahead of time, and the lesson's test checks it against the real plugin. It runs here with the real React 19. Type in its note box:
import { c as _c } from "react/compiler-runtime";import { useState } from "react";function TeaList(t0) { const $ = _c(4); const { items } = t0; console.log("TeaList renders"); let t1; if ($[0] !== items) { t1 = items.map(_temp); $[0] = items; $[1] = t1; } else { t1 = $[1]; } let t2; if ($[2] !== t1) { t2 = <ul>{t1}</ul>; $[2] = t1; $[3] = t2; } else { t2 = $[3]; } return t2;}function _temp(tea) { return <li key={tea}>{tea}</li>;}export default function App() { const $ = _c(16); const [note, setNote] = useState(""); let t0; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = ["Green tea", "Black tea"]; $[0] = t0; } else { t0 = $[0]; } const [teas, setTeas] = useState(t0); let t1; if ($[1] === Symbol.for("react.memo_cache_sentinel")) { t1 = event => setNote(event.target.value); $[1] = t1; } else { t1 = $[1]; } let t2; if ($[2] !== note) { t2 = <input value={note} onChange={t1} placeholder="A note for the shop" />; $[2] = note; $[3] = t2; } else { t2 = $[3]; } let t3; let t4; if ($[4] !== teas) { t3 = () => setTeas([...teas, "Mint tea"]); t4 = teas.includes("Mint tea"); $[4] = teas; $[5] = t3; $[6] = t4; } else { t3 = $[5]; t4 = $[6]; } let t5; if ($[7] !== t3 || $[8] !== t4) { t5 = <button onClick={t3} disabled={t4}>Add Mint tea</button>; $[7] = t3; $[8] = t4; $[9] = t5; } else { t5 = $[9]; } let t6; if ($[10] !== teas) { t6 = <TeaList items={teas} />; $[10] = teas; $[11] = t6; } else { t6 = $[11]; } let t7; if ($[12] !== t2 || $[13] !== t5 || $[14] !== t6) { t7 = <>{t2}{t5}{t6}</>; $[12] = t2; $[13] = t5; $[14] = t6; $[15] = t7; } else { t7 = $[15]; } return t7;}The console stays quiet while you type. Press Add Mint tea: now the count beside TeaList renders goes up, because the teas really changed. It's the same result memo gave you in the last lesson, and you didn't write a line of it. The code is longer and full of $ and t1; you'll never edit it, but the next section reads it, because it shows exactly what the compiler does.
$, and checks its inputs before it does any work. Your browser runs the rewritten code.A restaurant hires an editor to go through its recipe book before opening. She doesn't change any dish. She writes small notes in the margins: which parts of a plate can be kept, and what to check before making them again. During service, the cooks follow the notes and skip repeated work. A recipe she can't be sure about, she leaves without notes.
- In real life: Your recipe book, written plainly
- In React: Your components, with no memo, useMemo or useCallback
- In real life: An editor who reads it once, before the restaurant opens
- In React: The compiler, which runs when you build your app
- In real life: Margin notes: "if the order is the same as last time, serve the plate already made"
- In React: Checks like
if ($[0] !== items)and kept results in$ - In real life: The cooks follow the marked-up book during service
- In React: Your users' browsers run the compiled code
- In real life: A recipe that says "add whatever is left in the pot" gets no notes
- In React: A component that breaks the Rules of React is left as written
The React Compiler adds memoization to your components and Hooks when you build your app. It keeps each value, function and JSX element it can, with a check of what it depends on, so you write plain code and React skips the repeated work. It only rewrites code that follows the Rules of React; anything else it leaves as written.
Reading the compiled code
Start with TeaList, lines 3 to 29 of the compiled block above. Line 1 imports c from react/compiler-runtime, renamed _c. It comes with React 19, so you install nothing for it.
- Line 4,
const $ = _c(4), asks React for a memo cache with 4 slots: an array that React keeps for thisTeaListon the page between renders, the way it keeps state. Every slot starts out empty. - Lines 5 to 7 take
itemsout of the props, and line 8 is yourconsole.log, untouched. - Line 10 asks whether
itemsis a different value from the one in slot 0. If it is, line 11 makes the list items again, line 12 stores the newitemsin slot 0, and line 13 stores the list in slot 1. If not, line 15 takes the list from slot 1. - Lines 18 to 24 do the same for the
<ul>element: slot 2 holds the list it was made from, slot 3 the element. - Lines 27 to 29: the arrow you passed to
mapuses nothing from the component, so the compiler moved it out as_temp, one function for every render.
That's useMemo, written out: a check of the dependencies with !==, then a kept result. Now App, from line 30. Line 31 asks for 16 slots. Lines 34 to 39 check for Symbol.for("react.memo_cache_sentinel"), the marker an empty slot holds: the starting array of teas is made on the first render and kept forever, because it depends on nothing. The note box's onChange on lines 42 to 47 is kept the same way, so it's one function for the page's whole life, like a useCallback with no dependencies.
The line that matters most is 79: t6 = <TeaList items={teas} />, inside a check of teas on line 78. The compiler keeps the JSX element itself. When you type, teas hasn't changed, so App returns the very same <TeaList> element object as last time, from slot 11. React sees an element with the same props object it rendered last time, and skips TeaList without calling it. No memo needed.
TeaList's four slots. Render 2 gets the same items: each check matches, so it reads the kept <li> list and the kept <ul> instead of making them again.In the playground, the same compiled shop runs on this page with React's own memo cache. After every render, the table copies a cache and marks each slot that got a new value in that render. Type a letter in the note box, then press Add Mint tea, and switch between the two caches.
compiled.jsx- Green tea
- Black tea
_c(16)| Slot | Holds | Value now |
|---|---|---|
$[0] | the first teas array | … |
$[1] | the note box's onChange | … |
$[2] | note, to compare | … |
$[3] | the <input> element | … |
$[4] | teas, to compare | … |
$[5] | the button's onClick | … |
$[6] | disabled: has Mint tea? | … |
$[7] | onClick, to compare | … |
$[8] | disabled, to compare | … |
$[9] | the <button> element | … |
$[10] | teas, to compare | … |
$[11] | the <TeaList> element | … |
$[12] | <input>, to compare | … |
$[13] | <button>, to compare | … |
$[14] | <TeaList>, to compare | … |
$[15] | the whole <>…</> | … |
App hasn't run yet.
Typing a letter gives only four of App's slots a new value: the note (slot 2), the new <input> (slot 3), the input again to compare (slot 12), and the whole fragment (slot 15). TeaList doesn't run at all. Add Mint tea changes teas, so it gives most of App's slots and all four of TeaList's a new value.
How does the compiler work out which values depend on what? The How the React Compiler works lesson, in the Under the hood stage, follows your code through each step of the compiler until it comes out as this code.
Finer than memo and useMemo
Compare the compiled shop with what you'd write by hand:
- Every piece has its own check. Typing makes a new
<input>, but keeps the<button>and the<TeaList>. By hand you'd need auseMemoper piece, and nobody writes that. - It keeps JSX, so children are skipped without
memo. The last lesson neededmemo(TeaList), plus kept props. Here the kept element does it. - It works after an early return. The Rules of Hooks forbid a
useMemoafter anif (…) return, but compiled code isn't a Hook call, so it can keep values there too.
Here is a menu that returns early when there are no teas, then sorts them. It's a component with props on its own, so it doesn't run here:
export default function TeaMenu({ teas }) {
if (teas.length === 0) {
return <p>No teas yet.</p>;
}
const sorted = [...teas].sort();
return (
<ul>
{sorted.map((tea) => (
<li key={tea}>{tea}</li>
))}
</ul>
);
}import { c as _c } from "react/compiler-runtime";
export default function TeaMenu(t0) {
const $ = _c(5);
const {
teas
} = t0;
if (teas.length === 0) {
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <p>No teas yet.</p>;
$[0] = t1;
} else {
t1 = $[0];
}
return t1;
}
let t1;
if ($[1] !== teas) {
const sorted = [...teas].sort();
t1 = sorted.map(_temp);
$[1] = teas;
$[2] = t1;
} else {
t1 = $[2];
}
let t2;
if ($[3] !== t1) {
t2 = <ul>{t1}</ul>;
$[3] = t1;
$[4] = t2;
} else {
t2 = $[4];
}
return t2;
}
function _temp(tea) {
return <li key={tea}>{tea}</li>;
}In the compiled menu, lines 7 to 16 handle the early return: the <p>No teas yet.</p> element is made once and kept in slot 0. Lines 18 to 25 come after it: the sorted copy and its list items are made again only when teas changes. Writing that with useMemo would mean moving the Hook above the if.
Two limits. The compiler only compiles components and Hooks, not plain helper functions; a slow helper is kept by the component that calls it. And each component keeps its own results: if three components call the same slow helper with the same data, it runs three times. For a helper that's really slow, cache it outside React, as in the JavaScript Memoization lesson.
Turning the compiler on
The compiler is a Babel plugin, babel-plugin-react-compiler. Babel is a tool that rewrites JavaScript as your app is built. Install the plugin as a dev dependency, a package your build uses but your users never download. React's blog suggests an exact version, so an upgrade never surprises you. (The tools and versions in this section were checked in October 2026; the latest compiler release was still 1.0.0.)
npm install --save-dev --save-exact babel-plugin-react-compiler@latestThen tell your build to use it. With Babel on its own, it goes in babel.config.js, first in the list, because it needs your code as you wrote it, before other plugins change it:
module.exports = {
plugins: [
"babel-plugin-react-compiler", // must run first
// ... your other plugins
],
};Vite projects use @vitejs/plugin-react. From its version 6 (for Vite 8), the compiler comes as a preset you pass to @rolldown/plugin-babel. Install that package and @babel/core too:
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()],
}),
],
});Line 2 imports reactCompilerPreset with react, line 3 the Babel plugin for Vite, and lines 8 to 10 run the preset. Version 6.1 also added an experimental compiler: true option that runs a Rust port of the compiler instead of Babel; it was still marked experimental in October 2026. Older versions of @vitejs/plugin-react took the Babel plugin directly:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: ["babel-plugin-react-compiler"],
},
}),
],
});Next.js has the option built in. Install the plugin, then set reactCompiler on line 4 of next.config.ts. It became a stable option in Next.js 16; Next.js 15 had it as experimental.reactCompiler. Next.js only runs the compiler on files with JSX or Hooks, which keeps builds fast.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;How to read the table: each row is a build tool, what you install for it, and where you turn the compiler on.
| Your build tool | What you install | Where you turn it on |
|---|---|---|
| Babel | babel-plugin-react-compiler | babel.config.js: put the plugin first in plugins. |
Vite, with @vitejs/plugin-react 6 | babel-plugin-react-compiler, @rolldown/plugin-babel and @babel/core | vite.config.js: add babel({ presets: [reactCompilerPreset()] }) after react(). |
| Next.js 16 | babel-plugin-react-compiler | next.config.ts: set reactCompiler: true. Next.js 15 called it experimental.reactCompiler. |
| Expo SDK 54 or later | Nothing more | It's on by default in new apps. |
New apps can start with it on: npm create vite@latest and npx create-next-app@latest offer templates with the compiler, and Expo apps have it on by default from SDK 54. With React 19, compiled code imports its cache from react/compiler-runtime, which React 19 includes. The compiler also works with React 17 and 18: set its target option to "17" or "18" and install the react-compiler-runtime package as a normal dependency.
To check it's working, open React Developer Tools: compiled components show a small "Memo" badge with a sparkle next to their names. Or search your built JavaScript for react/compiler-runtime.
Code the compiler can't optimize
The compiler assumes your code follows the Rules of React: components are pure (see Keeping components pure), props and state are never changed in place, refs are only read and written outside rendering, and Hooks follow the Rules of Hooks. Code that breaks a rule falls into one of two groups.
Rules it can see. Here is the render counter from Referencing values with refs, a mistake on purpose: line 6 reads and writes renders.current while rendering.
import { useRef, useState } from "react"; export default function App() { const [note, setNote] = useState(""); const renders = useRef(0); renders.current = renders.current + 1; return ( <> <input value={note} onChange={(event) => setNote(event.target.value)} placeholder="A note for the shop" /> <p>Renders: {renders.current}</p> </> );}The compiler reports Cannot access refs during render and leaves this component exactly as written, so it behaves the same with or without the compiler, StrictMode's double count included. Nothing breaks; this component just doesn't get faster. The rest of your app is still compiled. You see the same report in your editor through eslint-plugin-react-hooks: from version 7, its recommended preset includes the compiler's checks as lint rules. This is what npx eslint printed for it, shortened:
App.jsx
6:3 error Error: Cannot access refs during render
5 | const renders = useRef(0);
> 6 | renders.current = renders.current + 1;
| ^^^^^^^^^^^^^^^ Cannot update ref during render react-hooks/refs
6:21 error … Cannot access ref value during render react-hooks/refs
10:20 error … Cannot access ref value during render react-hooks/refs
✖ 3 problems (3 errors, 0 warnings)6:3 is line 6, column 3: the write to renders.current. 6:21 and 10:20 are the two reads. Under each one, ESLint explains: React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
Turning the rules on takes one line in a flat ESLint config. They work whether or not your app uses the compiler, so you can fix code before you turn it on:
import reactHooks from "eslint-plugin-react-hooks";
import { defineConfig } from "eslint/config";
export default defineConfig([
reactHooks.configs.flat.recommended,
]);The fix is to count something that happens in an event instead of in rendering. This version counts edits in the handler, with state, and follows every rule, so the compiler compiles it:
import { useState } from "react"; export default function App() { const [note, setNote] = useState(""); const [edits, setEdits] = useState(0); return ( <> <input value={note} onChange={(event) => { setNote(event.target.value); setEdits(edits + 1); }} placeholder="A note for the shop" /> <p>Edits: {edits}</p> </> );}Lines 10 to 13 run when you type: they set the note and add one to edits. Line 16 shows the count. Type hi: it says Edits: 2, with or without StrictMode or the compiler.
Rules it can't see. Some broken rules look fine to the compiler and to the lint rules. Then the compiler compiles the code, and its kept values can show old data. This app changes its state in place, a mistake on purpose: line 8 pushes into the teas array, and line 9 sets a counter only to make React render again.
import { useState } from "react"; export default function App() { const [teas] = useState(["Green tea"]); const [count, setCount] = useState(0); function addMint() { teas.push("Mint tea"); setCount(count + 1); } return ( <> <button onClick={addMint}>Add Mint tea</button> <p>{teas.join(", ")}</p> </> );}Press Add Mint tea: without the compiler it seems to work, and the paragraph shows Mint tea. Now the compiled version:
import { c as _c } from "react/compiler-runtime";import { useState } from "react";export default function App() { const $ = _c(11); let t0; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = ["Green tea"]; $[0] = t0; } else { t0 = $[0]; } const [teas] = useState(t0); const [count, setCount] = useState(0); let t1; if ($[1] !== count || $[2] !== teas) { const addMint = function addMint() { teas.push("Mint tea"); setCount(count + 1); }; t1 = <button onClick={addMint}>Add Mint tea</button>; $[1] = count; $[2] = teas; $[3] = t1; } else { t1 = $[3]; } let t2; if ($[4] !== teas) { t2 = teas.join(", "); $[4] = teas; $[5] = t2; } else { t2 = $[5]; } let t3; if ($[6] !== t2) { t3 = <p>{t2}</p>; $[6] = t2; $[7] = t3; } else { t3 = $[7]; } let t4; if ($[8] !== t1 || $[9] !== t3) { t4 = <>{t1}{t3}</>; $[8] = t1; $[9] = t3; $[10] = t4; } else { t4 = $[10]; } return t4;}Press Add Mint tea as often as you like: the paragraph says Green tea. Lines 28 to 34 keep teas.join(", ") in slot 5 until teas is a different value. push changes the array but keeps it the same array, so the check on line 28 never sees a change. The bug was always there; the compiler just stopped covering it up. Follow the rule and make a new array, as Updating arrays in state teaches, and the compiled version works:
import { c as _c } from "react/compiler-runtime";import { useState } from "react";export default function App() { const $ = _c(9); let t0; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = ["Green tea"]; $[0] = t0; } else { t0 = $[0]; } const [teas, setTeas] = useState(t0); let t1; let t2; if ($[1] !== teas) { const addMint = function addMint() { setTeas([...teas, "Mint tea"]); }; t1 = <button onClick={addMint}>Add Mint tea</button>; t2 = teas.join(", "); $[1] = teas; $[2] = t1; $[3] = t2; } else { t1 = $[2]; t2 = $[3]; } let t3; if ($[4] !== t2) { t3 = <p>{t2}</p>; $[4] = t2; $[5] = t3; } else { t3 = $[5]; } let t4; if ($[6] !== t1 || $[7] !== t3) { t4 = <>{t1}{t3}</>; $[6] = t1; $[7] = t3; $[8] = t4; } else { t4 = $[8]; } return t4;}Line 17 gives setTeas a new array, so teas is a new value, the check on line 15 sees it, and the paragraph updates.
One more kind of code can change when it's compiled: code that works only because something was memoized, like an effect that should run once and lists an object as its dependency. The compiler may keep values more or less often than your own useMemo did, so such an effect can run more or less often. Make effects correct without memoization (see Removing effect dependencies), and test your app when you turn the compiler on or upgrade it.
Directives: opting in and out
A directive is a string on its own as the first line of a function or a file, like "use strict". The compiler reads two. By default it runs in infer mode: it compiles functions named like components (a capital first letter) or Hooks (starting with use) that make JSX or call Hooks. Directives change that for one function:
function teaLabel(tea) {
return <b>{tea}</b>;
}
function TeaCard({ tea }) {
"use no memo";
return <li>{teaLabel(tea)}</li>;
}
export default function TeaShelf({ teas }) {
return (
<ul>
{teas.map((tea) => (
<TeaCard key={tea} tea={tea} />
))}
</ul>
);
}import { c as _c } from "react/compiler-runtime";
function teaLabel(tea) {
return <b>{tea}</b>;
}
function TeaCard({
tea
}) {
"use no memo";
return <li>{teaLabel(tea)}</li>;
}
export default function TeaShelf(t0) {
const $ = _c(4);
const {
teas
} = t0;
let t1;
if ($[0] !== teas) {
t1 = teas.map(_temp);
$[0] = teas;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] !== t1) {
t2 = <ul>{t1}</ul>;
$[2] = t1;
$[3] = t2;
} else {
t2 = $[3];
}
return t2;
}
function _temp(tea) {
return <TeaCard key={tea} tea={tea} />;
}These are parts of a page, not a whole example, so they don't run here. In the compiled file:
teaLabel(lines 2 to 4) is left alone. It returns JSX, but its name starts with a small letter, so it isn't treated as a component.TeaCard(lines 5 to 11) is left as written, because"use no memo"is its first line. Babel only adds an empty line after the directive.TeaShelf(lines 12 to 34) is compiled, with a cache of 4 slots.
A directive only counts as the very first statement. Anywhere else it's an ordinary string that does nothing, so the compiler compiles the function (Exercise 6). "use no memo" is for a while: when a bug might come from the compiler, opt the component out, and if the bug goes away, look for a broken rule in it. Leave a comment saying why, and remove the directive once it's fixed.
"use memo" does the opposite. You rarely need it in infer mode. It matters in annotation mode, where the compiler compiles only functions that ask for it:
module.exports = {
plugins: [
["babel-plugin-react-compiler", { compilationMode: "annotation" }],
],
};With that setting, a file with a TeaList that starts with "use memo" and a CartBadge without it gets TeaList compiled and CartBadge left as written, even though both are components. How to read the table: each row is what you write, what the compiler does with it, and when you'd use it.
| What you write | What the compiler does | When to use it |
|---|---|---|
| Nothing (the default, infer mode) | Compiles functions named like components (TeaList) or Hooks (useCart) that make JSX or call Hooks. | Almost always. |
"use memo" as a function's first line | Compiles that function, even in annotation mode. | When you opt components in one at a time. |
"use no memo" as a function's first line | Leaves that function exactly as you wrote it. | For a while, to find out whether the compiler causes a bug. |
Adopting it step by step
A new app can turn the compiler on everywhere on day one. An existing app usually goes step by step:
- Turn on the lint rules first. Fix what
eslint-plugin-react-hooksreports, at your own pace. Every fix is a component the compiler can compile later. - Compile one part of the app. Babel's
overridesrun the plugin on one folder only, like the checkout below. Or use annotation mode and add"use memo"to a few components. Larger teams can use the compiler'sgatingoption, which keeps both versions of each component and picks one at run time from a feature flag. - Test, then widen. Run your tests and use the app. If something breaks, opt that component out with
"use no memo", find the broken rule, and fix it. - Turn it on everywhere, and keep the version exact, so an upgrade happens only when you choose and test it.
module.exports = {
overrides: [
{
test: "./src/checkout/**/*.{js,jsx,ts,tsx}",
plugins: ["babel-plugin-react-compiler"],
},
],
};Line 4 picks the files: everything under src/checkout. Line 5 runs the compiler on those files only.
What about the memo, useMemo and useCallback already in your code? React's advice: leave them, because removing them can change the compiled output, or remove them only with careful testing. In new code, write plain components and let the compiler memoize. Keep useMemo and useCallback for when you need exact control, like a value an effect depends on that must stay the same.
The shop page, before and after
Here is the shop page from the last lesson, written plainly: no memo, useMemo or useCallback. Each card in the ProductGrid.jsx tab waits 10 ms on purpose (lines 2 to 5 of that tab), so five cards make every render of the grid slow, and line 14 of that tab logs ProductGrid renders. Type quickly in the note box:
import { useState } from "react";import ProductGrid from "./ProductGrid.jsx"; const products = [ { name: "Green tea", price: 3 }, { name: "Black tea", price: 2 }, { name: "Mint tea", price: 4 }, { name: "Lemon tea", price: 3 }, { name: "Jasmine tea", price: 5 },]; export default function App() { const [query, setQuery] = useState(""); const [note, setNote] = useState(""); const [cart, setCart] = useState([]); const visible = products.filter((product) => product.name.toLowerCase().includes(query.toLowerCase())); function addToCart(product) { setCart([...cart, product]); } return ( <> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Find a tea" /> <input value={note} onChange={(event) => setNote(event.target.value)} placeholder="A note for the shop" /> <p>Items in cart: {cart.length}</p> <ProductGrid products={visible} onAdd={addToCart} /> </> );}Typing feels sticky, and the count beside ProductGrid renders goes up with every key: line 17 filters the products again and makes a new array, and lines 19 to 21 make a new addToCart, so line 28 passes the grid new props every time. Now the same two files, compiled:
import { c as _c } from "react/compiler-runtime";import { useState } from "react";import ProductGrid from "./ProductGrid.jsx";const products = [{ name: "Green tea", price: 3}, { name: "Black tea", price: 2}, { name: "Mint tea", price: 4}, { name: "Lemon tea", price: 3}, { name: "Jasmine tea", price: 5}];export default function App() { const $ = _c(21); const [query, setQuery] = useState(""); const [note, setNote] = useState(""); let t0; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = []; $[0] = t0; } else { t0 = $[0]; } const [cart, setCart] = useState(t0); let t1; if ($[1] !== query) { t1 = products.filter(product => product.name.toLowerCase().includes(query.toLowerCase())); $[1] = query; $[2] = t1; } else { t1 = $[2]; } const visible = t1; let t2; if ($[3] !== cart) { t2 = function addToCart(product_0) { setCart([...cart, product_0]); }; $[3] = cart; $[4] = t2; } else { t2 = $[4]; } const addToCart = t2; let t3; if ($[5] === Symbol.for("react.memo_cache_sentinel")) { t3 = event => setQuery(event.target.value); $[5] = t3; } else { t3 = $[5]; } let t4; if ($[6] !== query) { t4 = <input value={query} onChange={t3} placeholder="Find a tea" />; $[6] = query; $[7] = t4; } else { t4 = $[7]; } let t5; if ($[8] === Symbol.for("react.memo_cache_sentinel")) { t5 = event_0 => setNote(event_0.target.value); $[8] = t5; } else { t5 = $[8]; } let t6; if ($[9] !== note) { t6 = <input value={note} onChange={t5} placeholder="A note for the shop" />; $[9] = note; $[10] = t6; } else { t6 = $[10]; } let t7; if ($[11] !== cart.length) { t7 = <p>Items in cart: {cart.length}</p>; $[11] = cart.length; $[12] = t7; } else { t7 = $[12]; } let t8; if ($[13] !== addToCart || $[14] !== visible) { t8 = <ProductGrid products={visible} onAdd={addToCart} />; $[13] = addToCart; $[14] = visible; $[15] = t8; } else { t8 = $[15]; } let t9; if ($[16] !== t4 || $[17] !== t6 || $[18] !== t7 || $[19] !== t8) { t9 = <>{t4}{t6}{t7}{t8}</>; $[16] = t4; $[17] = t6; $[18] = t7; $[19] = t8; $[20] = t9; } else { t9 = $[20]; } return t9;}Typing in the note box is instant and the console stays quiet. Lines 33 to 39 keep the filtered list until query changes, lines 42 to 50 keep addToCart until cart changes, and lines 91 to 98 keep the <ProductGrid> element until either one changes. Press Add: the grid renders, because cart changed and so did addToCart. That's fine: the compiler keeps what it can prove is unchanged, and nothing more. Type le in the search box: the grid renders for the new list, as it should. Sort each function below by what the compiler does with it, in its default mode:
function TeaBadge({ count }) { return <b>{count}</b>; }function Greeting({ name }) { return <h1>Hello, {name}</h1>; }function usePricesWithTax(items) { const [tax] = useState(0.1); return items.map((item) => item.price * (1 + tax)); }function formatPrice(price) { return "$" + price; }function priceTag(price) { return <b>${price}</b>; }function Counter() { const renders = useRef(0); renders.current = renders.current + 1; return <p>{renders.current}</p>; }function FreeTea({ tea }) { tea.price = 0; return <p>{tea.name}</p>; }function Banner() { "use no memo"; return <p>Sale today</p>; }function Cart() { const [count, setCount] = useState(0); setCount(1); return <p>{count}</p>; }
Each card is a whole function in a file the compiler reads. Sort it: compiled, skipped because it breaks a rule, or left alone because it isn't a component or Hook, or opts out.
Common mix-ups
- "The compiler runs in the browser." No. It runs once, when you build your app. The browser runs the code it wrote, with React 19's memo cache.
- "It wraps every component in
memo." No. It keeps finer pieces: each value, function and JSX element, with its own check. - "It fixes code that breaks the rules." No. It skips the code it can see is wrong, and code it can't see can show old data once compiled. The lint rules help you fix it.
- "A lint error means my app is broken." No. The compiler just leaves that component as written. Fix the errors at your own pace.
- "I must delete my
useMemoanduseCallbackcalls." No. They keep working. React suggests leaving existing ones in place, or removing them only with careful testing. - "
"use no memo"works anywhere in the function." No. Only as the first statement. Anywhere else it's an ordinary string. - "It needs React 19." No. React 19 includes its cache. React 17 and 18 work with the
targetoption and thereact-compiler-runtimepackage. - "It speeds up every slow function." No. It compiles only components and Hooks, and each component keeps its own results. A slow helper shared by many components still runs once in each.
Practice exercises
The compiled code in these exercises is the real output of babel-plugin-react-compiler 1.0.0. The live examples can't compile your edits, so the exercises ask you to read compiled code, predict what the compiler does, and fix the code you write.
In the compiled tea shop, what does TeaList's $[0] hold after its first render?
import { c as _c } from "react/compiler-runtime";
import { useState } from "react";
function TeaList(t0) {
const $ = _c(4);
const {
items
} = t0;
console.log("TeaList renders");
let t1;
if ($[0] !== items) {
t1 = items.map(_temp);
$[0] = items;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] !== t1) {
t2 = <ul>{t1}</ul>;
$[2] = t1;
$[3] = t2;
} else {
t2 = $[3];
}
return t2;
}
function _temp(tea) {
return <li key={tea}>{tea}</li>;
}
export default function App() {
const $ = _c(16);
const [note, setNote] = useState("");
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = ["Green tea", "Black tea"];
$[0] = t0;
} else {
t0 = $[0];
}
const [teas, setTeas] = useState(t0);
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = event => setNote(event.target.value);
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] !== note) {
t2 = <input value={note} onChange={t1} placeholder="A note for the shop" />;
$[2] = note;
$[3] = t2;
} else {
t2 = $[3];
}
let t3;
let t4;
if ($[4] !== teas) {
t3 = () => setTeas([...teas, "Mint tea"]);
t4 = teas.includes("Mint tea");
$[4] = teas;
$[5] = t3;
$[6] = t4;
} else {
t3 = $[5];
t4 = $[6];
}
let t5;
if ($[7] !== t3 || $[8] !== t4) {
t5 = <button onClick={t3} disabled={t4}>Add Mint tea</button>;
$[7] = t3;
$[8] = t4;
$[9] = t5;
} else {
t5 = $[9];
}
let t6;
if ($[10] !== teas) {
t6 = <TeaList items={teas} />;
$[10] = teas;
$[11] = t6;
} else {
t6 = $[11];
}
let t7;
if ($[12] !== t2 || $[13] !== t5 || $[14] !== t6) {
t7 = <>{t2}{t5}{t6}</>;
$[12] = t2;
$[13] = t5;
$[14] = t6;
$[15] = t7;
} else {
t7 = $[15];
}
return t7;
}import { c as _c } from "react/compiler-runtime";import { useState } from "react";function TeaList(t0) { const $ = _c(4); const { items } = t0; console.log("TeaList renders"); let t1; if ($[0] !== items) { t1 = items.map(_temp); $[0] = items; $[1] = t1; } else { t1 = $[1]; } let t2; if ($[2] !== t1) { t2 = <ul>{t1}</ul>; $[2] = t1; $[3] = t2; } else { t2 = $[3]; } return t2;}function _temp(tea) { return <li key={tea}>{tea}</li>;}export default function App() { const $ = _c(16); const [note, setNote] = useState(""); let t0; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = ["Green tea", "Black tea"]; $[0] = t0; } else { t0 = $[0]; } const [teas, setTeas] = useState(t0); let t1; if ($[1] === Symbol.for("react.memo_cache_sentinel")) { t1 = event => setNote(event.target.value); $[1] = t1; } else { t1 = $[1]; } let t2; if ($[2] !== note) { t2 = <input value={note} onChange={t1} placeholder="A note for the shop" />; $[2] = note; $[3] = t2; } else { t2 = $[3]; } let t3; let t4; if ($[4] !== teas) { t3 = () => setTeas([...teas, "Mint tea"]); t4 = teas.includes("Mint tea"); $[4] = teas; $[5] = t3; $[6] = t4; } else { t3 = $[5]; t4 = $[6]; } let t5; if ($[7] !== t3 || $[8] !== t4) { t5 = <button onClick={t3} disabled={t4}>Add Mint tea</button>; $[7] = t3; $[8] = t4; $[9] = t5; } else { t5 = $[9]; } let t6; if ($[10] !== teas) { t6 = <TeaList items={teas} />; $[10] = teas; $[11] = t6; } else { t6 = $[11]; } let t7; if ($[12] !== t2 || $[13] !== t5 || $[14] !== t6) { t7 = <>{t2}{t5}{t6}</>; $[12] = t2; $[13] = t5; $[14] = t6; $[15] = t7; } else { t7 = $[15]; } return t7;}Line 12 stores items in $[0], so the next render can compare its items with it on line 10. On the first render that's the ["Green tea", "Black tea"] array from App's state.
In its default mode, does the compiler compile teaLabel in this file?
function teaLabel(tea) {
return <b>{tea}</b>;
}
function TeaCard({ tea }) {
"use no memo";
return <li>{teaLabel(tea)}</li>;
}
export default function TeaShelf({ teas }) {
return (
<ul>
{teas.map((tea) => (
<TeaCard key={tea} tea={tea} />
))}
</ul>
);
}import { c as _c } from "react/compiler-runtime";
function teaLabel(tea) {
return <b>{tea}</b>;
}
function TeaCard({
tea
}) {
"use no memo";
return <li>{teaLabel(tea)}</li>;
}
export default function TeaShelf(t0) {
const $ = _c(4);
const {
teas
} = t0;
let t1;
if ($[0] !== teas) {
t1 = teas.map(_temp);
$[0] = teas;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] !== t1) {
t2 = <ul>{t1}</ul>;
$[2] = t1;
$[3] = t2;
} else {
t2 = $[3];
}
return t2;
}
function _temp(tea) {
return <TeaCard key={tea} tea={tea} />;
}In the compiled file, lines 2 to 4 are teaLabel exactly as written. Infer mode compiles functions named like components or Hooks; rename it TeaLabel and use it as <TeaLabel tea={tea} /> if you want it compiled.
This is the compiler's output for a small cart. It has just loaded. You type hi in the note box, then press Add a tea once. By how much does the count beside CartSummary renders in the console go up?
import { c as _c } from "react/compiler-runtime";
import { useState } from "react";
function CartSummary(t0) {
const $ = _c(2);
const {
count
} = t0;
console.log("CartSummary renders");
let t1;
if ($[0] !== count) {
t1 = <p>Items in cart: {count}</p>;
$[0] = count;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
export default function App() {
const $ = _c(10);
const [note, setNote] = useState("");
const [count, setCount] = useState(0);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = event => setNote(event.target.value);
$[0] = t0;
} else {
t0 = $[0];
}
let t1;
if ($[1] !== note) {
t1 = <input value={note} onChange={t0} placeholder="A note for the shop" />;
$[1] = note;
$[2] = t1;
} else {
t1 = $[2];
}
let t2;
let t3;
if ($[3] !== count) {
t2 = <button onClick={() => setCount(count + 1)}>Add a tea</button>;
t3 = <CartSummary count={count} />;
$[3] = count;
$[4] = t2;
$[5] = t3;
} else {
t2 = $[4];
t3 = $[5];
}
let t4;
if ($[6] !== t1 || $[7] !== t2 || $[8] !== t3) {
t4 = <>{t1}{t2}{t3}</>;
$[6] = t1;
$[7] = t2;
$[8] = t3;
$[9] = t4;
} else {
t4 = $[9];
}
return t4;
}import { c as _c } from "react/compiler-runtime";import { useState } from "react";function CartSummary(t0) { const $ = _c(2); const { count } = t0; console.log("CartSummary renders"); let t1; if ($[0] !== count) { t1 = <p>Items in cart: {count}</p>; $[0] = count; $[1] = t1; } else { t1 = $[1]; } return t1;}export default function App() { const $ = _c(10); const [note, setNote] = useState(""); const [count, setCount] = useState(0); let t0; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = event => setNote(event.target.value); $[0] = t0; } else { t0 = $[0]; } let t1; if ($[1] !== note) { t1 = <input value={note} onChange={t0} placeholder="A note for the shop" />; $[1] = note; $[2] = t1; } else { t1 = $[2]; } let t2; let t3; if ($[3] !== count) { t2 = <button onClick={() => setCount(count + 1)}>Add a tea</button>; t3 = <CartSummary count={count} />; $[3] = count; $[4] = t2; $[5] = t3; } else { t2 = $[4]; t3 = $[5]; } let t4; if ($[6] !== t1 || $[7] !== t2 || $[8] !== t3) { t4 = <>{t1}{t2}{t3}</>; $[6] = t1; $[7] = t2; $[8] = t3; $[9] = t4; } else { t4 = $[9]; } return t4;}The <CartSummary> element is kept until count changes. Typing hi changes only note, so App reuses the element and React skips CartSummary: the count doesn't move. The click changes count, so CartSummary renders once, and StrictMode logs it twice: the count goes up by 2.
This app works without the compiler but shows old data once compiled. Rewrite it so it follows the rules. Which function does addMint call after your fix?
import { useState } from "react"; export default function App() { const [teas] = useState(["Green tea"]); const [count, setCount] = useState(0); function addMint() { teas.push("Mint tea"); setCount(count + 1); } return ( <> <button onClick={addMint}>Add Mint tea</button> <p>{teas.join(", ")}</p> </> );}import { useState } from "react"; export default function App() { const [teas, setTeas] = useState(["Green tea"]); function addMint() { setTeas([...teas, "Mint tea"]); } return ( <> <button onClick={addMint}>Add Mint tea</button> <p>{teas.join(", ")}</p> </> );}setTeas([...teas, "Mint tea"]) makes a new array, so teas is a new value after each click. The result here is the compiler's real output for this fix: its check $[1] !== teas sees the new array and the paragraph updates.
Which eslint-plugin-react-hooks rule reports line 6? Then change the app to count something that happens in an event instead.
import { useRef, useState } from "react"; export default function App() { const [note, setNote] = useState(""); const renders = useRef(0); renders.current = renders.current + 1; return ( <> <input value={note} onChange={(event) => setNote(event.target.value)} placeholder="A note for the shop" /> <p>Renders: {renders.current}</p> </> );}import { useState } from "react"; export default function App() { const [note, setNote] = useState(""); const [edits, setEdits] = useState(0); return ( <> <input value={note} onChange={(event) => { setNote(event.target.value); setEdits(edits + 1); }} placeholder="A note for the shop" /> <p>Edits: {edits}</p> </> );}react-hooks/refs reports line 6 (and the read on line 10): refs aren't for rendering. Counting edits with state in the change handler follows the rules, so the compiler compiles it and the count is the same with or without StrictMode.
You want the compiler to leave this TeaCard alone while you look for a bug, but it still compiles it. On which line must "use no memo" be?
export default function TeaCard({ tea }) {
const label = tea.toUpperCase();
"use no memo";
return <li>{label}</li>;
}export default function TeaCard({ tea }) {
"use no memo";
const label = tea.toUpperCase();
return <li>{label}</li>;
}As the first statement, on line 2, "use no memo" opts the component out, and the compiler leaves it as written. On line 3, after const label, it's an ordinary string, and the compiler compiles the component with a cache of 4 slots.
Check your understanding
For each question, ask what the compiler can prove from the code: what each value depends on, and whether a rule is broken.
Question 1 of 8What does the React Compiler do to your components?
Choose an answer to see the explanation.
Question 2 of 8In compiled code, what does this block do when
itemsis the same value as last render?Read the code, then predictJavaScriptif ($[0] !== items) { t1 = items.map(_temp); $[0] = items; $[1] = t1; } else { t1 = $[1]; }Choose an answer to see the explanation.
Question 3 of 8In its default mode, which of these does the compiler compile?
Choose an answer to see the explanation.
Question 4 of 8A component reads
renders.currentfrom a ref while it renders. What does the compiler do?Choose an answer to see the explanation.
Question 5 of 8Does the compiler compile this
TeaCard?Read the code, then predictJSXexport default function TeaCard({ tea }) { const label = tea.toUpperCase(); "use no memo"; return <li>{label}</li>; }Choose an answer to see the explanation.
Question 6 of 8This app changes its state in place. With the compiler, Add Mint tea is clicked once. What does the paragraph show?
Read the code, then predictJSXimport { useState } from "react"; export default function App() { const [teas] = useState(["Green tea"]); const [count, setCount] = useState(0); function addMint() { teas.push("Mint tea"); setCount(count + 1); } return ( <> <button onClick={addMint}>Add Mint tea</button> <p>{teas.join(", ")}</p> </> ); }Choose an answer to see the explanation.
Question 7 of 8You turn the compiler on in an app full of
useMemoanduseCallback. What do React's docs suggest?Choose an answer to see the explanation.
Question 8 of 8In a React 19 app, where does compiled code get its memo cache?
Choose an answer to see the explanation.
Key takeaways
- The React Compiler is a Babel plugin that adds memoization to components and Hooks when you build your app.
- Compiled code asks React for a memo cache,
$ = _c(n), and keeps each value, function and JSX element in a slot with a check of what it depends on. - It keeps JSX elements, so children are skipped without
memo, and it can keep values after an early return. - Turn it on with
babel-plugin-react-compiler: first in Babel's plugins,reactCompilerPresetin Vite, orreactCompiler: truein Next.js 16. - It skips code that breaks a rule it can see, and
eslint-plugin-react-hooksreports it. A broken rule it can't see can show old data once compiled. "use no memo"opts a function out and"use memo"opts one in, as its first statement.- Adopt it step by step: lint first, then a folder or annotation mode, then everywhere, with an exact version.
Remember the one-liner.
Write plain components that follow the Rules of React, and the compiler keeps whatever it can prove hasn't changed.
Coming next: Profiling React apps. It finds what's really slow, with the React Developer Tools profiler and the browser's performance tools.