Blog

Part 22 · The React Compiler: What It Does for You

The React Compiler adds memoization to your components when you build the app. See the code it writes, what it skips, how to turn it on with Vite, and what you still do by hand.

In Part 20 and Part 21 we stopped extra renders by hand. We wrapped a child in memo. We kept objects and functions the same with useMemo and useCallback. It worked, but it was easy to get wrong.

The React Compiler is a tool that does this work for you. A compiler is a program that reads your code and writes new code from it. This one runs when you build your app. It adds memoization to your components and hooks. For code that follows React’s rules, you don’t have to change it.

This part shows the code it writes, and what it can’t fix. Then we turn it on in the project from Part 10.

One thing first. The playground on this page does not run the compiler. So every “with the compiler” result here comes from running the real compiler on our computer: babel-plugin-react-compiler 1.0.0 with React 19.3.0.

Try this first

Read this code. Don’t press Run yet.

import { useState, type CSSProperties } from 'react'

function Price({ style, onBuy }: { style: CSSProperties; onBuy: () => void }) {
  console.log('Price renders')
  return (
    <p style={style}>
      Price: 5 <button onClick={onBuy}>Buy</button>
    </p>
  )
}

export default function App() {
  const [name, setName] = useState('')
  const [bought, setBought] = useState(0)
  return (
    <div>
      <input aria-label="Your name" value={name} onChange={(e) => setName(e.target.value)} />
      <p>Hello, {name}!</p>
      <Price style={{ color: 'teal' }} onBuy={() => setBought(bought + 1)} />
      <p>Bought: {bought}</p>
    </div>
  )
}
Price renders
Price renders
Price renders
Price renders
Price renders
Price renders
Price renders
Price renders

Price gets two props. style is an object, made new in each render of App. onBuy is a small arrow function, also made new each time.

Make two guesses. You type “Ana” in the box, one key at a time. How many times will “Price renders” appear while you type? And how many times would it appear if the compiler had built this code?

Press Run and type “Ana”.

The Console shows 8 lines. Two came when the page loaded. Then two came for each of the 3 keys. Each pair is one render, run twice by Strict Mode, as Part 2 explained.

Each key changes name, so App renders again. And when a parent renders, its children render too. That was Part 18.

We built this exact code with the compiler and ran it the same way, with Strict Mode on. While we typed “Ana”, “Price renders” appeared 0 times. Then we clicked Buy once. Price rendered, and logged its 2 lines.

So the compiler stopped the extra renders, with no memo, no useMemo and no useCallback. Let’s see how.

What the compiler is

React’s docs call it “a new build-time tool that automatically optimizes your React app”. Build time is when a tool turns your files into the app your users get. In Part 10, that was npm run build, and the dev server did the same work for each file you opened.

The compiler doesn’t run in the browser. It only changes your code before the browser gets it.

A few facts about it:

  • It is a separate package. React doesn’t include it. A new Vite project doesn’t turn it on, unless you pick the “TypeScript + React Compiler” template.
  • It is a Babel plugin, babel-plugin-react-compiler. Babel is a tool that changes JavaScript code. A plugin is a piece of code that adds a feature to a tool. You met plugins in Part 10.
  • We call code that went through the compiler compiled code.
  • It is stable. The React team released version 1.0 on October 7, 2025. Their post says it is “fully production-ready”. On 7 October 2026, version 1.0.0 was still the newest on npm.
  • It works with React 17, 18 and 19. For 17 and 18, you set one option and add one more package. React 19 needs nothing extra.

If you saw older notes that called the compiler “experimental”, they were written before version 1.0.

The code the compiler writes

Let’s start with the smallest example. React’s install page shows this one, and we got exactly the same output from the compiler:

import { c as _c } from "react/compiler-runtime";
export default function MyApp() {
  const $ = _c(1);
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = <div>Hello World</div>;
    $[0] = t0;
  } else {
    t0 = $[0];
  }
  return t0;
}

Here is what each part does.

  • _c(1) asks React for a cache with 1 box. A cache is a place to keep answers so you can use them again. You met the word in Part 13. React keeps this cache for each copy of the component on the page, between renders.
  • $ is that cache. It is an array, so $[0] is its first box.
  • Symbol.for("react.memo_cache_sentinel") is a special value that means “this box is still empty”.
  • On the first render, the box is empty. So the code makes the <div> element and puts it in the box.
  • On every render after that, the code takes the element out of the box. It doesn’t make a new one.

_c comes from react/compiler-runtime. That file is part of React 19.

The code for “Try this first”

Now the app from “Try this first”. The compiler gave App a cache with 13 boxes, _c(13). These are the lines about Price. We left out the rest of App:

  let t3;
  if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
    t3 = {
      color: "teal"
    };
    $[4] = t3;
  } else {
    t3 = $[4];
  }
  let t4;
  let t5;
  if ($[5] !== bought) {
    t4 = <Price style={t3} onBuy={() => setBought(bought + 1)} />;
    t5 = <p>Bought: {bought}</p>;
    $[5] = bought;
    $[6] = t4;
    $[7] = t5;
  } else {
    t4 = $[6];
    t5 = $[7];
  }

The style object { color: "teal" } never changes. So box 4 has only the “empty box” check. The compiler makes the object once, keeps it in box 4, and uses that same object every time.

The <Price> element depends on bought. Box 5 keeps the value of bought from last time. $[5] !== bought asks: “is bought different from last time?” !== means “is not the same value or the same object”.

  • If bought changed, the code makes a new arrow function and a new <Price> element. It keeps both new bought and the new element in the cache.
  • If bought is the same, the code takes the old <Price> element out of box 6.

The input and the “Hello” line get their own check, $[1] !== name. So typing makes only those again, plus one more thing. The outer <div> holds all four parts. Box 12 keeps it, and it is made again whenever one of those parts is new. So typing makes a new <div> too.

Why Price was skipped

The compiler did not wrap Price in memo. Something else happened.

When you type, App still renders. But it returns the exact same <Price> element object as last time. Part 2 showed what React does then. The same element means the same props, so React skips Price.

Price got its own cache too, _c(5). That one keeps the <p> that Price returns. But it can’t stop Price itself from running. Only the parent can, by giving back the same element. We’ll see that this matters, under “use no memo” below.

React’s memo page says the compiler “automatically applies the equivalent of memo to all components”. Part 20 quoted it. The result is like memo when the parent is compiled too. But the skip comes from the parent, which gives back the same element. A child under a parent that wasn’t compiled still renders each time the parent does.

Here are the same steps as a picture.

1. You write App. No memo, no useMemo, no useCallback. 2. When you build, the compiler adds a cache to App. 3. First render: every box is empty. App makes everything and keeps it. 4. You type A. The name part changes, and so does the <div> holding it. 5. You click Buy. bought changed, so App makes a new Price element. function App() {…} React Compilerconst $ = _c(13)the cache inside App: one row per part of the result checks: nameempty– checks: nothing (made once)empty– checks: boughtempty– checks: the parts aboveempty– Price runs

What the compiler adds: same inputs, reuse the old result. A changed input, make only that part again. Press play, or step through it.

  1. You write App with no memo, no useMemo and no useCallback.
  2. When you build, the compiler adds a cache to App: const $ = _c(13). Each part of the result gets a box, and a check.
  3. On the first render every box is empty. App makes everything and keeps it. Price runs.
  4. You type “A”. Only name changed. So App makes the input and the “Hello” line again. It also makes a new outer <div>, because one part inside it is new. It uses the rest again. The <Price> element is the same object, so React skips Price.
  5. You click Buy. bought changed. So App makes a new arrow function and a new <Price> element, and Price runs.

An everyday example

Think of a cook with a small book of notes. For each dish, she writes down what went in and keeps the finished plate. The next order comes. If every part is the same as last time, she hands over the same plate. If one part changed, she makes just that part again.

The exact version

The compiler compares with !==. That checks for the same value or the same object, not the same contents. Here it works like Object.is from Part 20. MDN says the two differ only for NaN and for -0 and +0. A new object with the same fields counts as a change. Each box also keeps only the last answer, not every answer it has ever made.

And the cache belongs to one copy of one component. Two components can’t share it. We put two Report components on a page, with Strict Mode off. Each called the same slow function, total. On the first render, total ran 2 times, once for each. After that, typing in a text box on the same page ran it 0 times with the compiler. Without the compiler, 2 keys ran it 4 times. React’s docs say the same: the compiler’s memoization “is not shared across multiple components or hooks”.

Which functions it changes

The compiler doesn’t touch every function. It looks for components and hooks. Its docs say it picks functions named like a component (capital first letter) or a hook (starting with use). The function must also make JSX or call a hook.

We checked five kinds:

Function Did the compiler change it?
function Shop() that returns JSX yes
const Badge = ({ label }) => <b>{label}</b> yes
function useCart() that calls useState yes
function total(prices) with no JSX and no hooks no
function priceTag() that returns JSX no, but a call to it inside Shop was cached

So a small helper that starts with a small letter isn’t changed. Only the place where a component calls it is.

Turn it on in your Vite project

Now let’s add the compiler to the Part 10 project. We followed React’s install page and the docs of @vitejs/plugin-react, the Vite plugin from Part 10. We used a copy of the Part 10 project, with @vitejs/plugin-react 6.1.2 and Vite 8.3.3.

First, stop the dev server. Then install three packages:

npm install -D @rolldown/plugin-babel @babel/core babel-plugin-react-compiler

-D saves them as tools for building, not as code your users download. We got @rolldown/plugin-babel 0.2.4, @babel/core 8.0.6 and babel-plugin-react-compiler 1.0.0. (The compiler template below asks for @babel/core ^7.29.7 instead. Both worked.)

  • babel-plugin-react-compiler is the compiler.
  • @babel/core is Babel itself.
  • @rolldown/plugin-babel lets Vite run Babel on your files.

Then change vite.config.ts to this:

import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
import { defineConfig } from 'vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
})

reactCompilerPreset() comes from the React plugin. It holds the compiler’s settings for Vite. A preset is a ready-made set of settings.

Now run npm run build. It finished with no errors.

Check that it worked

How do you know the compiler ran? Search the build. We searched the JavaScript file in dist/assets for calls to the cache function. Before the change there were 0. After it there were 3, one for each component: App, Greeting and Counter.

This is Counter in the build before the change:

function p(){let[e,t]=(0,l.useState)(0);return console.log(`Counter renders, count =`,e),(0,f.jsxs)(`button`,{onClick:()=>t(e+1),children:[`Clicked `,e,` times`]})}

And after it:

function _(){let e=(0,h.c)(5),[t,n]=(0,l.useState)(0);console.log(`Counter renders, count =`,t);let r;e[0]===t?r=e[1]:(r=()=>n(t+1),e[0]=t,e[1]=r);let i;return e[2]!==t||e[3]!==r?(i=(0,g.jsxs)(`button`,{onClick:r,children:[`Clicked `,t,` times`]}),e[2]=t,e[3]=r,e[4]=i):i=e[4],i}

The names are short because the build is minified, as Part 10 showed. But you can see the pattern: (0,h.c)(5) asks for a cache with 5 boxes. e[2]!==t is a check like $[5] !== bought.

The dev server uses the compiler too. We asked it for src/Counter.tsx, and the code it sent had const $ = _c(6); in it. That is one box more than the build’s 5. The dev server keeps a long code made from the file in box 0. If the code doesn’t match, every box is emptied first. We changed one word in Counter.tsx and asked again, and the code was different. So after you save a file, its components start with an empty cache.

React’s docs give one more way to check. In the React Developer Tools browser add-on, compiled components show a “Memo ✨” badge. We didn’t check this one ourselves, because we didn’t open a browser for this part.

The quick way, and the cost

There is a faster way for a new project. When you run npm create vite@latest and pick React, one of the choices is “TypeScript + React Compiler”. We made a project with it and compared it with plain “TypeScript”. It had the same three packages, plus TypeScript types for Babel. Its vite.config.ts used the same babel({ presets: [reactCompilerPreset()] }).

Why isn’t it the default? The plain template’s own README.md says the compiler is not on “because of its impact on dev & build performances”. In plain words: the compiler adds work, so the dev server and the build get slower.

What the compiler skips

The compiler only works on code that follows the Rules of React. React’s docs put them in three groups: “Components and Hooks must be pure”, “React calls Components and Hooks”, and the “Rules of Hooks”. You met some already: call components with JSX, not as functions (Part 2). Don’t change props or state in place (Part 5). Don’t read or write a ref while rendering (Part 14).

What happens when the compiler sees a broken rule? React’s docs say “it safely skips optimization rather than risk changing your app’s behavior”. It leaves that component as you wrote it.

Reading a ref while rendering

The React Katas lesson for this part counts renders with a ref, in its “after” example. Let’s add the same counter to App:

import { useRef, useState, type CSSProperties } from 'react'

function Price({ style, onBuy }: { style: CSSProperties; onBuy: () => void }) {
  console.log('Price renders')
  return (
    <p style={style}>
      Price: 5 <button onClick={onBuy}>Buy</button>
    </p>
  )
}

export default function App() {
  const [name, setName] = useState('')
  const [bought, setBought] = useState(0)
  const renders = useRef(0)
  renders.current = renders.current + 1
  return (
    <div>
      <input aria-label="Your name" value={name} onChange={(e) => setName(e.target.value)} />
      <p>Hello, {name}!</p>
      <Price style={{ color: 'teal' }} onBuy={() => setBought(bought + 1)} />
      <p>Bought: {bought}</p>
      <p>App renders: {renders.current}</p>
    </div>
  )
}

In the playground this runs as before. The page says “App renders: 2” when it loads, and 2 more for each key, because Strict Mode renders twice. With the compiler, the build still worked. No error, no warning. But the compiler left App exactly as written, with no cache. The compiler can report what it does through an option called logger, which we turned on for this part. Without it, you see nothing. Its log said why:

Cannot access refs during render

So Price lost its skip. With Strict Mode on, typing “Ana” logged “Price renders” 6 times, the same as with no compiler at all. One small line turned the compiler off for the whole component, and nothing told us.

You can make the compiler stop instead. Set its panicThreshold option to 'all_errors'. Its docs suggest this only for a while, in development, to find problems. We tried it on this file, and the build stopped. The message began like this:

Found 3 errors:

Error: Cannot access refs during render

That is also true of the kata’s own “after” example. Its renderCount.current += 1 means the compiler skips it too. We checked.

Changing a prop

The compiler skipped this one too:

type User = { name: string; visits: number }

export function Card({ user }: { user: User }) {
  user.visits = user.visits + 1
  return <p>{user.name} visited {user.visits} times</p>
}

Its log said This value cannot be modified, and added Modifying component props or hook arguments is not allowed.

How to see what was skipped: the linter

The build won’t tell you. A linter will. That’s a tool that reads your code and warns about likely mistakes, from Part 10.

React’s docs say the compiler’s checks show up as rules for the linter, in eslint-plugin-react-hooks. Its recommended settings include rules called refs, immutability and purity, among others. We ran ESLint with eslint-plugin-react-hooks 7.1.1 on the two examples above. It gave errors for both: Cannot access refs during render (3 times, once for each use of renders.current), and This value cannot be modified.

The Part 10 project uses Oxlint, not ESLint. Does Oxlint have these rules? We checked Oxlint 1.87.0. Its list of rules has refs, immutability, purity and others with the same names. We ran it with the Part 10 project’s own settings, and it warned about both examples. Each warning ended with a note like this one:

note: React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/eslint-plugin-react-hooks/lints/refs

So a warning from one of these compiler rules, with that note, means the component isn’t being compiled. Other warnings, like exhaustive-deps, don’t mean that.

The linter doesn’t catch every skip, though. Later we’ll see a useMemo with a missing item in its list. The compiler skipped that component. ESLint and Oxlint only gave the usual exhaustive-deps warning. Neither said the component was skipped.

What nobody catches

The linter and the compiler can only find what they can see. This one slips past all of them:

import { useState } from 'react'

const fruits = ['apple', 'orange']

function List({ items }: { items: string[] }) {
  items.push('banana')
  return <p>{items.join(', ')}</p>
}

export default function App() {
  const [clicks, setClicks] = useState(0)
  return (
    <div>
      <button onClick={() => setClicks(clicks + 1)}>Clicks: {clicks}</button>
      <List items={fruits} />
    </div>
  )
}

items.push('banana') changes the prop array in place. That breaks a rule. But the compiler did not notice. It compiled List anyway. ESLint gave no message for this file. Oxlint said nothing about the push line either, even with every rule turned on.

And now the app shows different things with and without the compiler. With Strict Mode off:

First render After 1 click After 2 clicks
no compiler apple, orange, banana apple, orange, banana, banana apple, orange, banana, banana, banana
compiler apple, orange, banana apple, orange, banana apple, orange, banana

Without the compiler, each render of List adds one more “banana”. With it, App keeps the same <List> element in its cache, behind the “empty box” check. So React skips List after the first render, and the push never runs again. We added a log line to List to check. With the compiler, List ran once, and the array stayed at 3 items. Without it, List ran on every click.

The two pages agree only by luck. The bug is still the push. Where do problems in compiled code come from? React’s docs say they typically come from code that breaks a rule “in subtle ways that the compiler couldn’t detect”. The fix is the one from Part 5: don’t change the prop. Make a new array instead. Practice 2 below does that.

Press Run above and click a few times. The playground has no compiler, and Strict Mode renders twice. So you get two more “banana”s on each click.

"use no memo": turn it off for one function

Sometimes you need to know whether the compiler causes a problem. React has a directive for that. A directive is a short string at the very top of a function. It gives a tool an order. This one tells the compiler to leave the function alone:

import { useState } from 'react'

export function NameBox() {
  'use no memo'
  const [name, setName] = useState('')
  return <input aria-label="Your name" value={name} onChange={(e) => setName(e.target.value)} />
}

We put 'use no memo' at the top of App in “Try this first”. The compiler left App as written. Its log said it skipped the function because of a directive. Price was still compiled. But typing “Ana” logged “Price renders” 6 times again, with Strict Mode on.

That shows what we saw earlier. Price had its own cache, but it ran anyway. Only App, the parent, can give back the same <Price> element. And App now made a new one each time.

The directive must be the first line in the function. Comments before it are fine. It must use single or double quotes. We tried the third kind of JavaScript quote, the one used for template strings. The compiler compiled App anyway. Debugging means finding and fixing a bug. React’s docs say this directive is “a temporary debugging tool, not a permanent solution”. So use it to find a problem, then fix the problem and remove it.

Effects: what you still need to know

Part 11 showed an object made during render, used as an Effect’s dependency. Here is the same problem again. This code pretends to connect to a chat room:

import { useEffect, useState } from 'react'

function Chat({ room }: { room: string }) {
  const [text, setText] = useState('')
  const options = { room }
  useEffect(() => {
    console.log('connect to', options.room)
    return () => console.log('disconnect from', options.room)
  }, [options])
  return <input aria-label="Message" value={text} onChange={(e) => setText(e.target.value)} />
}

export default function App() {
  const [open, setOpen] = useState(true)
  return (
    <div>
      <button onClick={() => setOpen(false)}>Leave</button>
      {open && <Chat room="music" />}
    </div>
  )
}
connect to music
disconnect from music
connect to music
disconnect from music
connect to music
disconnect from music
connect to music
disconnect from music

Run it, type “Hi”, then press Leave. The first three lines come from Strict Mode’s setup, cleanup, setup when the page loads (Part 11). Then each key cleans up and connects again. options is a new object on each render, so the Effect sees a change. The last line comes from Leave, which removes Chat.

With the compiler, typing “Hi” logged nothing. The compiler kept options in its cache, and made it again only when room changed. So the Effect stopped running on every key.

That sounds good. But think about what it means. Your Effect now runs more or less often depending on whether the compiler ran. If someone later adds a ref read to Chat, the compiler skips it. Then the Effect runs on every key again, and nobody changed the Effect.

React’s docs warn about exactly this. They list “Effects that rely on referential equality” as a common way the compiler can break an app. Referential equality means being the very same object.

So write the Effect so that it works without the compiler. Make the object inside the Effect, and list room:

import { useEffect, useState } from 'react'

function Chat({ room }: { room: string }) {
  const [text, setText] = useState('')
  useEffect(() => {
    const options = { room }
    console.log('connect to', options.room)
    return () => console.log('disconnect from', options.room)
  }, [room])
  return <input aria-label="Message" value={text} onChange={(e) => setText(e.target.value)} />
}

export default function App() {
  const [open, setOpen] = useState(true)
  return (
    <div>
      <button onClick={() => setOpen(false)}>Leave</button>
      {open && <Chat room="music" />}
    </div>
  )
}
connect to music
disconnect from music
connect to music
disconnect from music

Typing logged nothing more, with the compiler and without it. Only Leave added its line. And both ESLint and Oxlint still warn about the first version. ESLint said The 'options' object makes the dependencies of useEffect Hook (at line 9) change on every render. So keep listening to exhaustive-deps from Part 11, even with the compiler on.

What about useMemo, useCallback and memo?

With the compiler, you need them less. React’s docs on memo say: “When you enable React Compiler, you typically don’t need React.memo anymore.”

But their advice is careful. For new code, they say to rely on the compiler. Then use useMemo and useCallback “where needed to achieve precise control”. For code you already have, they suggest this: “leaving existing memoization in place (removing it can change compilation output) or carefully testing before removing the memoization”.

We saw two things about existing useMemo calls:

  • A correct useMemo was replaced. The compiled code had no useMemo call left, only the compiler’s own cache. It checked the same two values, items and query.
  • A useMemo with a missing dependency made the compiler skip the whole component. Its log said: Existing memoization could not be preserved. The compiler worked out that the answer depends on query, but the list said only [items].

So you still need what Parts 20 and 21 taught:

  • When the compiler isn’t there. The playground has none. A new Vite project doesn’t turn it on, unless you pick the compiler template.
  • When it skips a component. Then that component behaves as if you never added the compiler.
  • When you need exact control. React’s docs keep useMemo and useCallback as “an escape hatch”. The common case is a value used as an Effect dependency, as in Part 21.
  • To read the compiled code. $[5] !== bought is a dependency list check. It’s the same idea as useMemo‘s list.

Common mistakes

Thinking the playground runs the compiler

It doesn’t. No example in this series is compiled. Run “Try this first” and you’ll see Price render on every key. That is the right answer for code without the compiler. To see the compiler work, you need a real project with it turned on.

Breaking a rule and losing the compiler without knowing

import { useRef, useState } from 'react'

export default function App() {
  const [name, setName] = useState('')
  const renders = useRef(0)
  renders.current = renders.current + 1
  return (
    <div>
      <input aria-label="Your name" value={name} onChange={(e) => setName(e.target.value)} />
      <p>App renders: {renders.current}</p>
    </div>
  )
}

The build works and the page works. But the compiler skipped App, and nothing in the build said so. By default the compiler skips quietly. Its docs call that setting panicThreshold: 'none', and recommend it for real builds. While you look for problems in development, you can set panicThreshold: 'all_errors' for a while. Then the build stops with Found 3 errors, as we saw above.

The fix: run the linter, and fix what it reports. Here, remove the render counter. To count renders, use a console.log or the React DevTools Profiler, which Part 36 covers.

Removing every useMemo and useCallback at once

The docs say removing existing memoization “can change compilation output”. And a value used by an Effect may now change at different times. Remove them one at a time, and test after each one. Or leave them.

Making an Effect depend on the compiler

If an Effect only behaves when the compiler is on, it is still a bug. It comes back when the compiler skips that component. Make objects inside the Effect, as shown above.

Copying old setup code

You may find setup code online, like this:

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

React’s install page says this babel option was removed in @vitejs/plugin-react 6.0.0. We tried it with 6.1.2. npm run build checks types first, as Part 10 showed, so it stopped with this error:

vite.config.ts(7,7): error TS2353: Object literal may only specify known properties, and 'babel' does not exist in type 'Options'.

The dev server doesn’t check types, so it started as usual. It printed no warning. But the Counter.tsx it sent had no cache in it: the compiler never ran. Running npx vite build by itself also skips the type check. That build finished, with 0 calls to the cache function. Use reactCompilerPreset() as shown above, and then check the build.

The kata’s setup also sets runtimeModule: 'react-compiler-runtime'. That option isn’t on React’s list of compiler options. In our test, it changed nothing: the code still imported from react/compiler-runtime. For React 17 or 18, the documented option is target: '18' (or '17'). With it, the code imported from the react-compiler-runtime package instead.

Practice

  1. Make “Try this first” skip Price in the playground, without the compiler. Use what Parts 20 and 21 taught. How many times does “Price renders” appear while you type “Ana”? And after one click on Buy?
  2. Fix the “What nobody catches” example, so that List doesn’t change its prop. What does the page show after two clicks?
  3. Run the fixed Chat from “Effects: what you still need to know”, and type “Hi”. Don’t press Leave. How many lines does the Console show?
  4. Which of these would the compiler change, with its default settings? function formatPrice(n: number) that returns a string. function PriceTag() that returns JSX. function useCart() that calls useState. const Badge = () => <b>New</b>.
Answers
  1. Three changes. Move the style object out of App: const tealText = { color: 'teal' }. Keep the function with const handleBuy = useCallback(() => setBought(bought + 1), [bought]). And wrap Price in memo. Then typing “Ana” logs nothing. The page load logs 2 lines. One click on Buy logs 2 more, because bought changed and so did handleBuy. The lines come in pairs because of Strict Mode.
  2. Change the two lines in List to const all = [...items, 'banana'] and return <p>{all.join(', ')}</p>. After two clicks the page shows “Clicks: 2 apple, orange, banana”. We got the same with the compiler and without it, and with Strict Mode on.
  3. 3 lines, all when the page loads: connect to music, disconnect from music, connect to music. Typing adds none, because room doesn’t change. Leave would add one more line, disconnect from music.
  4. PriceTag, useCart and Badge. formatPrice starts with a small letter and has no JSX and no hooks, so the compiler leaves it alone.

Interview questions

Try to answer each one out loud before you open the answer.

What is the React Compiler, and when does it run?

It is a build-time tool that adds memoization to your components and hooks. It runs when your app is built, as a Babel plugin, babel-plugin-react-compiler. It doesn’t run in the browser. It reads your code and writes new code with a small cache in each component.

A strong answer adds that it is a separate package, not part of React. Version 1.0 has been stable since October 7, 2025, and it works with React 17, 18 and 19.

What does compiled code look like?

Each compiled component starts with const $ = _c(N). That asks React for a cache of N boxes, kept between renders. Then each part of the result is wrapped in a check, like if ($[5] !== bought). If the value changed, the code makes the part again and stores it. If not, it uses the stored part again. A part with no inputs is made once, behind a special “empty box” value.

Does the compiler wrap every component in React.memo?

No. It caches values and JSX elements inside each component. A child is skipped because its parent gives back the very same element object as last time. React skips a child whose element didn’t change, unless the child’s own state or a context it reads changed.

So the result is like memo when the parent is compiled too. React’s memo page puts it that way: “the equivalent of memo”. But the skip comes from the parent.

A strong answer shows the difference. With "use no memo" on the parent, the compiled child still rendered on every key. Its own cache couldn’t stop that, because the parent made a new element each time.

What happens when your code breaks the Rules of React?

If the compiler sees it, it skips that component and leaves it as written. The build doesn’t fail by default, so this is easy to miss. The linter shows most cases. eslint-plugin-react-hooks has rules like refs and immutability, and Oxlint has rules with the same names. It doesn’t show every skip: a useMemo with a missing dependency only got an exhaustive-deps warning. For a full check, set panicThreshold: 'all_errors' for a while in development.

If the compiler can’t see it, it may compile the component anyway. Then the app can act differently. In our test, a push on a prop array changed the page. With the compiler, the list stopped growing. Without it, the list kept growing.

Should you delete all useMemo and useCallback calls when you add the compiler?

No. React’s docs suggest leaving existing memoization in place, or testing carefully before removing it. Removing it can change the compiled output. For new code, rely on the compiler and use the hooks where you need exact control. The common case is a value used as an Effect’s dependency.

How do you check that the compiler is working?

Look at the build output for the cache calls. They look like _c(13), or like (0,h.c)(5) once the build makes the names short. React’s docs also say compiled components show a “Memo ✨” badge in React DevTools. And run the linter: a warning from a compiler rule, like refs, means that component was skipped. An exhaustive-deps warning alone doesn’t tell you that.

What does "use no memo" do, and when should you use it?

It is a directive. Put it as the first line of a function, and the compiler leaves that function as written. Use it to find out if a bug comes from the compiler. React’s docs call it a temporary debugging tool. Fix the real problem, which is usually a broken rule, and then remove it.

Sources

  • Introduction, react.dev: “a new build-time tool”, what kind of memoization it adds, not shared across components, React 17 to 19, and what to do about useMemo, useCallback and memo.
  • Installation, react.dev: the Vite setup with reactCompilerPreset, the removed babel option, the compiled MyApp example, the “Memo ✨” badge, and the ESLint rules.
  • Debugging and Troubleshooting, react.dev: “it safely skips optimization”, rule breaks the compiler couldn’t detect, and “Effects that rely on referential equality”.
  • React Compiler v1.0, React blog, October 7, 2025: the stable release, “fully production-ready”, and the advice on existing memoization.
  • “use no memo”, compilationMode, panicThreshold, target and logger, react.dev: the directive’s rules, which functions are compiled, skipping instead of failing, React 17 and 18, and the compiler’s log.
  • eslint-plugin-react-hooks and its refs rule, react.dev: the compiler’s checks as lint rules.
  • Rules of React, react.dev: the three groups of rules.
  • Object.is(), MDN: how it differs from === (only NaN and signed zeros).
  • memo, react.dev: “you typically don’t need React.memo anymore” with the compiler, and “the equivalent of memo”.
  • The @vitejs/plugin-react 6.1.2 README.md, and the create-vite 9.2.1 templates react-ts and react-compiler-ts: the packages, the Vite config and the note about build speed.
  • The compiled code, render counts, Effect runs, compiler logs, lint messages and build results above come from running babel-plugin-react-compiler 1.0.0, Babel 8.0.6 and React 19.3.0 for this post, plus ESLint 10.12.0 with eslint-plugin-react-hooks 7.1.1, Oxlint 1.87.0, and a copy of the Part 10 project with Vite 8.3.3.
  • This part follows the React 19 Compiler kata in react-katas. The kata calls the compiler experimental and shows its output as useMemo calls. Today it is stable, and its output is the cache shown above. The kata’s “after” example reads a ref while rendering, so the compiler skips it.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.