0% completed
Automatic Memoization with React Compiler
On This Page
What React Compiler Does
Turning It On in a Vite Project
The Chapter's Examples, Compiled
What the Compiler Does Not Memoize
How the Compiler Changes This Chapter's Advice
Opting a Component Out, or In
The ParentComponent from the useCallback lesson, written with no memo and no useCallback, rendered its child again after every click on "Increment Count" and "Toggle Other State". We then passed exactly the same source file through React Compiler and ran it again. This time the child rendered once, when the page first appeared, and React skipped it on the same clicks:
Parent plain | mount: 1 | Increment: 1 | Toggle: 1 | Click Me: ["Button clicked!"] Parent compiled | mount: 1 | Increment: 0 | Toggle: 0 | Click Me: ["Button clicked!"]
Each number counts the Rendering ChildComponent messages printed after that step. Nobody edited the component between the two runs; the build added the memoization.
What React Compiler Does
React Compiler is a tool that runs while your project builds. It reads each component and each custom hook and works out which props, state and other values each part of the output depends on. It then rewrites the function so that it saves those parts and reuses them while their inputs stay the same. It is stable, it supports React 17, 18 and 19 and works best with React 19, and it is optional: a project adds it to its build configuration. It needs no changes to your code, as long as the code follows the rules of React, such as rendering without side effects and never changing state or props directly.
Here is part of the code the compiler produced for ParentComponent. We trimmed the two buttons and the outer div, which are handled the same way:
import { c as _c } from "react/compiler-runtime"; export default function ParentComponent() { const $ = _c(11); const [count, setCount] = useState(0); const [otherState, setOtherState] = useState(false); const handleClick = _temp; let t0; if ($[0] !== count) { t0 = <h1>Count: {count}</h1>; $[0] = count; $[1] = t0; } else { t0 = $[1]; } let t1; if ($[2] === Symbol.for("react.memo_cache_sentinel")) { t1 = <ChildComponent onClick={handleClick} />; $[2] = t1; } else { t1 = $[2]; } // ... the two buttons and the outer div follow the same pattern return t4; } function _temp() { console.log("Button clicked!"); }
You never write or edit this code, but reading it once explains what the compiler adds. Follow one click on "Increment Count":
_c(11)gives the component an array of 11 slots that React keeps between renders, in the same way it keeps state. The compiler uses the array as a cache.countis now 1, so the check$[0] !== countis true. The heading is built again and stored together with the newcount.- The child element depends only on
handleClick. BecausehandleClickreads no props or state, the compiler moved it out of the component as_temp, so it is the same function on every render. Slot 2 held a special "empty" marker only on the first render, so the element was built then and is returned from the cache on every later render. - React receives the same element object as last time, with the same props object inside it. It therefore skips calling
ChildComponent, with the same result as wrapping the child inmemo.
The diagram follows the cache through the first render and the click. Notice that the compiler did not wrap ChildComponent in memo. The child is skipped because the parent hands React the same element, so the skip depends on the parent being compiled.
Turning It On in a Vite Project
A project created with npm create vite@latest my-app -- --template react uses @vitejs/plugin-react version 6. For version 6 and later, the plugin's documentation lists three development packages:
npm install -D @rolldown/plugin-babel @babel/core babel-plugin-react-compiler
// vite.config.js 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()], }), ], });
We created a new project this way, installed the three packages, replaced vite.config.js with the file above, and ran npx vite build. The build succeeded, and the bundled ParentComponent contained the cache code shown earlier. The first package runs Babel, a tool that transforms source code, inside Vite's build. The compiler itself is a Babel plugin, and reactCompilerPreset is a ready-made configuration that applies it to your source files.
Versions of the plugin before 6 take the compiler through a babel option of react() instead, so check the installation guide for the version your project uses. A project on React 17 or 18 also passes its version to the preset, for example reactCompilerPreset({ target: '18' }). It installs the react-compiler-runtime package as a regular dependency, not a development one, because the compiled code imports it when the app runs.
To confirm that a component was compiled, open the app in development mode with React Developer Tools installed. Compiled components show a "Memo" badge with a small sparkle symbol next to their names.
The Chapter's Examples, Compiled
We compiled the plain versions of this chapter's examples, with every memo, useMemo and useCallback removed, and repeated the steps of each lesson:
| Example and step | Plain | Compiled |
|---|---|---|
ParentComponent: child renders after Increment and Toggle | 2 | 0 |
ProductSearch: filterProducts calls after mount, "mice" and 3 dark-mode toggles | 5 | 2 |
ProductSearch: ProductList renders over the same steps | 5 | 2 |
Theme provider with an inline value: ThemeButton renders while typing 3 letters | 3 | 0 |
A search function used by an effect: effect runs over mount and 3 re-renders | 4 | 1 |
ProductSearch plain | mount + "mice": filterRuns 2 | after 3 toggles: filterRuns 5 | ProductList renders 5 | 4000 products ProductSearch compiled | mount + "mice": filterRuns 2 | after 3 toggles: filterRuns 2 | ProductList renders 2 | 4000 products Theme plain (no memo anywhere) | ThemeButton renders while typing 3 letters: 3 | after toggle: 1 Theme: dark Theme compiled | ThemeButton renders while typing 3 letters: 0 | after toggle: 1 Theme: dark Effect plain | effect runs over mount + 3 re-renders: 4 Effect compiled | effect runs over mount + 3 re-renders: 1
Each compiled result matches the best hand-written version from the earlier lessons. In ProductSearch, the compiler stored the result of filterProducts(products, query) with query as its only input, which is what useMemo did by hand. It also stored the ProductList element with visible as its input, so the list was skipped on the toggles, as with useMemo and memo together.
The theme provider shows that the compiler handles a case that needed both hooks by hand. It stored the toggleTheme function once, because it only calls setTheme with an updater, and it rebuilt the value object only when theme changed. The test version had no memo on Page; the compiler stored the Page element instead, so the only renders left were those caused by a real change of the theme.
The same rules from the start of this chapter still decide every result. The compiler compares each stored input with its new value by identity, so it reuses a result only while each input is the same value or the same object.
What the Compiler Does Not Memoize
It works only inside components and hooks. The call filterProducts(products, query) was stored because it appears inside ProductSearch. The function filterProducts itself is not changed, and a call from ordinary code outside React is not cached.
Each component keeps its own cache. We added a CountBadge component that calls filterProducts with the same query. The compiled page filtered twice on mount and twice more for "mice", once in each component:
TwoReaders compiled | mount: filterRuns 2 | after "mice": 4 | after 1 toggle: 4 | 4000 found
The toggle caused no new calls, but the two components never shared a result. When two components need the same slow result, compute it once in a common parent and pass it down.
It skips code that breaks the rules of React. When the compiler finds code that might break those rules, it leaves the function unchanged instead of risking a change in its behavior. We compiled two components that count their own renders in ways the rules forbid:
import { useRef } from 'react'; let renderCount = 0; export function CountedList({ items }) { renderCount = renderCount + 1; // changes a variable outside the component return ( <ul data-renders={renderCount}> {items.map((p) => <li key={p.id}>{p.name}</li>)} </ul> ); } export function RefList({ items }) { const renders = useRef(0); renders.current = renders.current + 1; // writes a ref during rendering return ( <ul data-renders={renders.current}> {items.map((p) => <li key={p.id}>{p.name}</li>)} </ul> ); }
CountedList: CompileError "Cannot reassign variables declared outside of the component/hook" RefList: CompileError "Cannot access refs during render"
We printed those messages through the compiler's logging option. A normal npx vite build of the same file printed nothing and did not fail. Both functions came out of the build exactly as written, while a third component in the same file was compiled normally. React's documentation recommends the lint rules of eslint-plugin-react-hooks, which report code the compiler will skip, so you can fix it over time.
The compiler does not catch every violation. A component that sorted its items prop in place with items.sort(...) was compiled without any message, although sorting a prop in place changes data that belongs to the parent. After one render, the parent's array was in the new order. You still have to write code that follows the rules.
It cannot help a component whose parent is not compiled. The child skip in the first example came from the parent reusing the element. We opted the parent out of the compiler, as the last section of this lesson shows, and kept the child compiled. The child rendered again after every click on "Increment Count" and "Toggle Other State":
Parent compiled + use no memo | mount: 1 | Increment: 1 | Toggle: 1 | Click Me: ["Button clicked!"]
The compiler also does not make the first render faster. Like useMemo, it saves work only on later renders.
How the Compiler Changes This Chapter's Advice
With the compiler enabled, the advice of the earlier lessons changes in three ways:
- New code usually needs no
memo,useMemooruseCallback. Write the plain version, as in the table above, and let the compiler add the equivalent where it helps. useMemoanduseCallbackremain available for precise control. React's documentation names one common case: a value used as an effect dependency, where you must be sure the effect does not run again unless the value really changes.- Existing memoization should stay unless you test carefully after removing it, because removing it can change what the compiler produces.
The habits from the earlier lessons still matter. Measure before you decide that something is slow, keep rendering pure, and know that a new object or function defeats any comparison by identity. The compiler only applies those rules for you. You will still read memo, useMemo and useCallback in most React code written so far, and they behave exactly as this chapter measured.
The compiler can also change when effects run, because it may keep a value the same where your code used to create a new one, or the reverse. React's documentation names code that relies on memoization to work correctly, such as an effect that expects an object to stay the same object, as one of the main ways the compiler can break an app.
Opting a Component Out, or In
If a component behaves differently after compilation, add the directive "use no memo" as the first line of its body:
export default function ParentComponent() { 'use no memo'; const [count, setCount] = useState(0); // ... the rest is unchanged }
The compiler then skips that function, which is how we built the uncompiled parent in the section on what the compiler does not memoize. If the problem disappears, the component most likely breaks a rule of React. Fix the rule violation and remove the directive, because React's documentation says opt-out directives should be temporary.
A large existing app can also adopt the compiler one component at a time. With reactCompilerPreset({ compilationMode: 'annotation' }), the compiler processes only functions that start with "use memo". In our run of a file with two identical counters, only the one marked "use memo" was compiled. The opposite directive also works for a whole file: "use no memo" at the top of a file, before the imports, makes the compiler skip every function in that file. In annotation mode, however, a "use memo" placed at the top of a file compiled none of its functions in our Vite build, so mark each function you want compiled.
Reading Progress
0%
On This Page
What React Compiler Does
Turning It On in a Vite Project
The Chapter's Examples, Compiled
What the Compiler Does Not Memoize
How the Compiler Changes This Chapter's Advice
Opting a Component Out, or In