The React Compiler
What you'll learn
- What the React Compiler is: a build-time tool that memoizes your components for you
- Why hand-written memoization collapses at scale
- How the compiler thinks: reactive values, dependency graphs, and cache guards
- A component before and after compilation, walked through two renders
- What the compiler won't memoize, how to opt out, and what changes in your day-to-day code
The last two chapters taught you to maintain memo boundaries by hand: stabilize every prop, wrap every callback, audit every children, forever. It works, but it doesn't scale, it's tax on every edit. The React Compiler's pitch is audacious: delete all of it. Write the naive code you wish you could write, inline objects, inline arrows, no memo, no useMemo, no useCallback, and a build-time tool inserts memoization more precise than any human maintains. This chapter explains how that's possible without reading a line of its source.
Why hand memoization fails at scale
Three structural problems, none of them your fault:
- Memo is viral. For a deep subtree to actually skip rendering, every boundary on the path from the state change must hold. One unstable prop anywhere up the chain re-renders everything below. Coverage must be total or it's worthless, and total coverage by hand never survives contact with a real team.
- Unstable props leak in silently. Someone adds
options={{...}}inline six months after you memoized the component. No error, no warning, the boundary just quietly dies, and the Profiler is the only witness. - Humans forget dependencies. Every hand-written dep array is a chance to create a stale closure, a callback that sees last month's state. These are the worst bugs in React: everything looks fine until a specific sequence of interactions reads a ghost.
The common root: memoization is a mechanical job, figure out what depends on what, cache precisely, assigned to humans, who are bad at mechanical jobs. Machines are good at them. So: give the job to a machine.
What the compiler actually is
Jargon: "React Compiler". A build-time tool (running in your bundler pipeline, not in the browser) that reads your component source, analyzes what can change between renders, and emits new code with fine-grained memoization inserted automatically. Your runtime React doesn't change; your source gets transformed before it ever ships.
Key facts up front:
- It runs at build time. Zero analysis cost in the user's browser.
- It memoizes at a finer grain than components: individual values, individual JSX blocks.
React.memocan only skip whole components; the compiler can skip re-computing one expression inside a render that otherwise must happen. - It's a real, production-used tool from the React 19 era, enabled per-project through your build setup (a plugin in your bundler config). Not a research demo.
How it thinks: reactive values and the dependency graph
The compiler's first job is figuring out what can change.
Jargon: "reactive value". Any value that could be different from one render to the next: props, state, context reads, and everything computed from them. A module-level constant is not reactive;
props.useris. Reactivity is contagious: anything derived from a reactive value is reactive.
Step 1: analyze the component. The compiler reads the function body and marks every reactive value and every place it's used.
Step 2: build a dependency graph. For every computed value and every JSX block, it records precisely which reactive values it reads. filtered depends on items and query. The <p>Total: {total}</p> block depends on total. And so on, for everything, not just the things you happened to wrap.
Step 3: emit cache guards. React gives each component instance a small cache array. The compiler rewrites each computed value into a guarded slot:
Pseudocode model, not real source:
// The cache-guard shape the compiler emits, conceptually:if (cache[0] !== items || cache[1] !== query) {cache[2] = items.filter((i) => i.name.includes(query)); // recomputecache[0] = items;cache[1] = query;}const filtered = cache[2]; // fresh or cached, always correct
That's the entire trick, applied relentlessly: "if my inputs are the same objects as last time, my output is the same, reuse it." It is exactly useMemo's logic, but with a perfect, compiler-generated dependency list that can never go stale and never be forgotten. Event handlers get the same treatment (that's your useCallback), and JSX blocks too (cached element objects, which then trigger the same-element bailout from chapter 1, so child components skip rendering without any memo wrapper).
Before and after: one component, compiled
You write this, deliberately naive, everything inline:
import { useState } from 'react';
export default function ProductList({ products }) {
const [query, setQuery] = useState('');
const visible = products.filter((p) => p.name.includes(query));
const total = visible.reduce((sum, p) => sum + p.price, 0);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<p>Total: {total}</p>
<ul>
{visible.map((p) => (
<li key={p.id} onClick={() => console.log(p.id)}>
{p.name} — ${p.price}
</li>
))}
</ul>
</div>
);
}
Pseudocode model, not real source, the conceptual compiled output:
export default function ProductList({ products }) {const cache = getMyCache(8); // per-instance slots from Reactconst [query, setQuery] = useState('');// Guard 1: the derived list — depends on products + queryif (cache[0] !== products || cache[1] !== query) {cache[2] = products.filter((p) => p.name.includes(query));cache[0] = products;cache[1] = query;}const visible = cache[2];// Guard 2: the total — depends only on visibleif (cache[3] !== visible) {cache[4] = visible.reduce((sum, p) => sum + p.price, 0);cache[3] = visible;}const total = cache[4];// Guard 3: the input's onChange — depends on nothing but setQuery,// which React guarantees stable, so: cached once, forever.if (cache[5] === EMPTY) {cache[5] = (e) => setQuery(e.target.value);}// Guard 4: the whole JSX block — depends on query, total, visible...if (cache[6] !== query || cache[7] !== total /* …visible, handler */) {cache[8] = (<div><input value={query} onChange={cache[5]} /><p>Total: {total}</p><ul>{visible.map(/* each li + its onClick, also guarded */)}</ul></div>);cache[6] = query;cache[7] = total;}return cache[8];}
Render walk 1, you type a character (deps changed):
setQueryfires; the component re-renders.queryis new,productsis the same prop.- Guard 1:
cache[1] !== query→ recompute path.visibleis rebuilt; cache updated. - Guard 2:
visiblechanged identity → recomputetotal. - Guard 3:
cache[5]is set → the sameonChangefunction is reused. No new function object. - Guard 4:
queryandtotalchanged → the JSX block is rebuilt. New element tree, returned, diffed, committed. Necessary work, the UI genuinely changed.
Render walk 2, an unrelated parent re-render (deps same):
- Parent re-renders;
ProductListre-runs with the sameproductsprop and unchangedquerystate. - Guard 1:
productsandqueryare identical → cached path.visible = cache[2], zero filtering. - Guard 2:
visibleidentical → cachedtotal. - Guard 4: all deps identical →
cache[8], the same element object as last render, is returned. - React sees an identical element tree → the diff finds nothing → commit is empty. The re-render cost was a handful of
!==checks.
Notice what never appeared: useMemo, useCallback, memo, or a single dependency array you had to write. The naive source got exact, per-value memoization.
What it does NOT memoize: and the rules still apply
- Hook calls themselves are never skipped.
useState,useContext, your custom hooks, they run every render, always. The compiler caches values derived between hooks, not React's own machinery. (Hook order is load-bearing; that's Part 3.) - Module-level code, top-level constants and helper calls run at import time, once; there's nothing per-render to memoize.
- Event handlers and JSX are memoized, worth repeating, because that's the two things humans wrap in
useCallbackandReact.memo. Both automated. - The rules of React are now enforced by a reader. The compiler's analysis assumes purity: no mutating props or state in place, no conditional hooks, no side effects in render. Code that breaks the rules may make the compiler bail out (skip optimizing that component, silently correct but unoptimized) or error at build time with a precise explanation. Mutation-heavy code doesn't just risk bugs anymore, it visibly forfeits optimization.
- Opt-out: add
'use no memo'as the first line of a component or file and the compiler leaves it untouched, the escape hatch for generated code, legacy quirks, or debugging.
function LegacyChart({ data }) {
'use no memo'; // compiler: hands off this one
// ...
}
What changes for you
- Keep writing idiomatic React. Inline objects, inline arrows, derived values computed in render, the "naive" style is now the fast style.
- Stop reaching for
useMemo/useCallbackby default. Their remaining legitimate role shrinks to: values passed to other libraries' APIs with identity-sensitive behavior, and dependencies of effects where identity semantics matter to your logic. Not performance sprinkling. - Measure first, still. The compiler removes wasted render work. It cannot fix a tree that's too big, a layout that thrashes, or state shaped wrongly (previous chapter). Architecture is still your job; bookkeeping is now the machine's.
- Follow the rules you were always supposed to follow. Purity stops being aspirational and starts paying rent.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: The compiler is a runtime that watches your app. Reality: it's a build-time transform. The analysis happens once, on your machine, before shipping; the browser just runs guarded code.
- Misconception: It's "automatic
React.memoon every component." Reality: finer than that, it memoizes individual values and JSX blocks inside renders that still happen, which component-level memo can never do. - Misconception: With the compiler, rules of React matter less. Reality: more. Mutation and conditional hooks now cost you optimization or fail the build, with the rule-breaking component named.
- Misconception: Existing
useMemo/useCallbackmust be ripped out before adopting it. Reality: hand-written memoization is compatible; it's just usually redundant afterwards. Remove it gradually, or don't. - Misconception: It memoizes hooks and module code too. Reality: hook calls always run; module code runs once at import. Only per-render derived values, handlers, and JSX get guards.
- Misconception: It's vaporware. Reality: it's a shipped, production-used tool of the React 19 era, opt-in per project through the build setup.
Why it works this way
- Memoization is inference, and inference is what compilers do. "What does this value depend on?" has a definite, mechanical answer in your source code. Humans answering it by hand is how stale closures are born; a tool answering it is exact.
- A per-instance cache array works because hook order is fixed. The compiler can assign stable slot indexes for the same reason hooks can live in a linked list: component bodies execute in the same order every render (Part 3's rule, reused).
- Identity comparison is sound because of the purity contract.
Object.is-equal inputs imply equal outputs only if render is pure, which is why the compiler enforces the rules instead of merely hoping. - Cached JSX piggybacks the element bailout. Emitting the same element object means React's same-identity skip (chapter 1) does subtree-level optimization for free, no
memowrapper required.
Try it yourself
- Take the
ProductListsource above and hand-simulate the compiled version: on paper, mark which guards recompute whenquerychanges, and which when only the parent re-renders. Check against the two render walks. - In a project with the compiler enabled, write a deliberately un-memoized list with inline handlers, then use the Profiler to confirm rows don't re-render on unrelated state changes, no
memoanywhere. - Add
'use no memo'to one component and observe (viaconsole.count) that its inline-handler children re-render again, the opt-out working as advertised. - Deliberately mutate a prop inside a component (
props.list.push(x)) in a compiler-enabled project and read the build-time diagnostic. Connect the error message back to the purity rules from Part 3.
Recap
- The React Compiler is a build-time tool: naive idiomatic code in, precisely memoized code out. Runtime React is unchanged.
- Hand memoization fails at scale because it's viral, silently defeated by unstable props, and one forgotten dep away from a stale-closure bug.
- The compiler marks reactive values, builds a dependency graph of every expression and JSX block, and emits cache guards against a per-instance cache array.
- Deps identical → cached path, often returning the same element object, which triggers React's same-element bailout. Any dep changed → recompute just that slot.
- Hooks and module code are never memoized; handlers and JSX are. The rules of React are enforced: impure code bails out or errors.
'use no memo'opts out. - Your new defaults: write idiomatic code, drop reflexive
useMemo/useCallback, fix state architecture, and measure before optimizing anything.