Skip to content
Chapter 26Lesson 2

The React Compiler

How the React Compiler in Next.js 16 inserts your memoization at build time and ends hand-written useMemo and useCallback.

Open a React codebase from 2024 and you’ll see the same texture everywhere: every derived value sits in a useMemo, every handler in a useCallback, every leaf component behind React.memo. That wrapping defends against the waste you spent the last few chapters learning to see, where a parent re-renders, a freshly-built object prop reads as a new reference under Object.is, and a whole subtree re-renders for nothing.

The wrapping never fully works. Doing it by hand means tracking every dependency of every value across every render, and humans miss some. One stray inline style={{ padding: 8 }} slips through and re-renders the subtree the memo was meant to protect. So you write the defensive code, review it in every pull request, maintain it as the code changes, and it still leaves gaps.

The React Compiler ends that. Stable since its 1.0 release in October 2025 and a config option in Next.js 16, it reads your components at build time and inserts the memoization for you, at the boundaries that matter, without the gaps. By the end of this lesson you’ll turn it on in a Next.js 16 project, say precisely what it does and doesn’t do, and confirm in DevTools that it’s working. The shift to internalize: memoization stops being something you write.

In one sentence: the React Compiler is a build-time tool that analyzes your component and hook bodies and inserts the memoization equivalent to the useMemo, useCallback, and memo you would have written by hand.

Build-time is the load-bearing word. The compiler runs once, when your project is built, the same moment your TypeScript becomes JavaScript and your modules get bundled. Nothing from it ships at runtime: no React Compiler library in your bundle, no scheduler, no extra layer between your components and the screen.

So the output is ordinary memoized React, the same code an expert would have written by tracking every dependency perfectly. The transform is deterministic and inspectable, and it produces the kind of code you already read every day.

It decides what to memoize through static analysis, tracing at build time which values feed which outputs. If a derived value depends only on items, the compiler recomputes it when items changes and reuses the previous result when it doesn’t. That is the same shape useMemo always had: recompute on changed inputs, reuse on stable ones.

The diagram below traces one small component through the build. Scrub through the steps to see where the work happens.

Your source SWC selects Babel transforms Shipped output
CartSummary.tsx
const CartSummary = ({ items }) => {
  const total = items.reduce(
    (sum, i) => sum + i.price, 0);
  return <button onClick={() =>
    checkout(total)}>Pay {total}</button>;
};
format-currency.ts
export const format = (n) =>
  `$${n.toFixed(2)}`;

Your source: a plain component with a derived total and an inline onClick, no memoization written by hand. Beside it, a plain util with no JSX.

Your source SWC selects Babel transforms Shipped output
CartSummary.tsx selected
const CartSummary = ({ items }) => {
  const total = items.reduce(
    (sum, i) => sum + i.price, 0);
  return <button onClick={() =>
    checkout(total)}>Pay {total}</button>;
};
format-currency.ts skipped
export const format = (n) =>
  `$${n.toFixed(2)}`;

Next.js’s SWC pass picks only files with JSX or hooks. The plain util is skipped, so builds stay fast.

Your source SWC selects Babel transforms Shipped output
CartSummary.tsx transformed
const CartSummary = ({ items }) => {
const total = … ✨ memoized
return (
<button onClick={…}> ✨ memoized
);
};

The React Compiler’s Babel plugin rewrites the body, inserting memo slots for the derived value and the callback.

Your source SWC selects Babel transforms Shipped output
Ordinary memoized React same runtime behavior

What ships is plain React, optimally memoized. No compiler runs at runtime.

Two points carry forward. First, selection: Next.js’s SWC pipeline hands the compiler only files with JSX or hooks, so a plain utility module is never analyzed and your builds skip code that has nothing to memoize. Second, the transform produces ordinary output, not a runtime engine.

“It inserts the memoization you’d have written” gets concrete once you map it onto traps you already know. Each pattern the compiler handles is a render-waste pattern from the chapters on the render model and the built-in hooks, now removed for you.

  • Derived values computed during render. The in-render work you met under “derive, don’t mirror”: a filtered list, a running total, a formatted label. The compiler caches the result keyed on its inputs, so it isn’t recomputed every render.
  • Object and array literals passed as props. <Sidebar config={{ theme, size }} /> is the canonical referential identity trap: a fresh literal every render is a new reference, so the child re-renders. The compiler keeps the reference stable when the contents don’t change.
  • Callbacks defined inside the component. <Row onClick={() => deleteItem(id)} /> keeps a stable identity as long as the values it captures don’t change, so you no longer reach for useCallback.
  • Provider values. <ThemeContext value={{ user, theme }}> is the context re-render storm: an unstable provider value re-renders every consumer on every parent render. The compiler stabilizes it, dropping the manual useMemo you used to wrap around each one.
  • JSX subtrees. A part of the tree that doesn’t depend on what changed is reused instead of re-rendered.

That last item is the payoff, and it’s easier to see than to read. The widget below has an App holding a counter that renders a Sidebar with an inline config={{ ... }} prop. Switch tabs and bump the counter in each.

Bumping a counter in App

The win is the box that stops lighting up. In the first tab, bumping an unrelated counter re-renders Sidebar, because the inline config is a brand-new reference every render. Stabilizing that reference is the auto-memoization the compiler handles: in the second tab Sidebar sees the same config, Object.is agrees, and the subtree is skipped, with no useMemo from you.

You turn the compiler on once, in two steps.

  1. Install the compiler as a dev dependency.

    Terminal window
    pnpm add -D babel-plugin-react-compiler

    The React Compiler ships as a Babel plugin, yet Next.js builds with SWC, not Babel. Next.js bridges the gap: it wraps the compiler in an SWC step that feeds it only the files containing JSX or hooks, so you get the compiler’s analysis where it’s needed and builds stay fast.

  2. Set reactCompiler: true in your Next.js config.

    next.config.ts
    import type { NextConfig } from 'next';
    const nextConfig: NextConfig = {
    reactCompiler: true,
    };
    export default nextConfig;

There is no per-file opt-in: reactCompiler: true turns on full coverage across the whole app, and that is the right default for a new project. You enable it on day one and let it carry memoization for the entire codebase, dropping the habit of reaching for useMemo and useCallback as you write.

A second mode exists for one situation you won’t hit on a new project: moving a large existing codebase onto the compiler gradually rather than all at once. The first tab below is the default you just wired up; the second is annotation mode.

next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;

A new 2026 project starts here. reactCompiler: true compiles every component and hook, with no per-file opt-in. Skip the rest of this section unless you’re migrating an old codebase.

The file-by-file migration workflow is the next lesson’s topic. Here, you only need to know that annotation mode is the on-ramp for an old codebase, not something you configure on a new one.

The compiler’s job is narrow and well-defined: memoization. Knowing its boundaries keeps you from expecting powers it doesn’t have.

It does not rewrite your effects. useEffect and its dependency array are left exactly as you wrote them; the compiler never moves logic into or out of an effect.

It does not change the rules of hooks. Hooks still go at the top level, called in the same order every render, and the two ESLint checks that enforce this stay on. The compiler relies on those rules; it doesn’t replace them.

It does not eliminate dependency arrays. You still list the dependencies for each effect. The compiler memoizes values inside render, not the dependency list of a useEffect.

It does not memoize impure code, and that is the hinge into the next section. If its analysis detects a violation of the Rules of React , such as a component that mutates something during render, a hook called conditionally, or an initializer with a side effect, it does not produce wrong output. It skips that component and emits a warning.

Two things you’ve already learned fit in here without conflict. useEffectEvent carves out the non-reactive seam inside an effect, the line you want to run on the latest props without re-subscribing; the compiler handles memoization everywhere else. Between them they cover both halves of the effect problem. Refs fit just as cleanly: React 19 made ref a normal prop, so the compiler reads a component that takes ref like any other prop.

Before moving on, draw the boundary yourself by sorting each item onto the side it belongs to.

The compiler has one job. Sort each concern by whether the compiler handles it or it stays your responsibility. Drag each item into the bucket it belongs to, then press Check.

The compiler handles this Auto-memoization
Still your job Contract you uphold
Inline object prop stabilization
Provider value stability
Derived-value memoization
Callback prop stability
Effect dependency arrays
Calling hooks in order
Cleanup functions
Not mutating props during render

The four on the right share a theme: they’re the contract you uphold. The compiler only takes over the memoization once you’ve held up your end.

When the compiler skips a component and warns, it isn’t breaking your code, it’s reporting a bug that was already there. The compiler reports problems; it doesn’t cause them.

A codebase that worked before the compiler often worked only because manual memoization papered over an impurity. A component that mutates one of its props, an initializer that pushes onto a module-level array, a render that calls a function with a side effect: these are latent bugs, wrong all along. When the compiler’s analysis hits one, it refuses to optimize that component and warns. It’s pointing at what was already broken.

This is the purity contract from the render-model chapter: much of React’s machinery works only when render stays pure. The compiler optimizes only the components it can prove pure. So the fix is never to silence the warning, it’s to correct the violation. Here is the smallest version of that.

const RankedList = ({ items }: { items: Item[] }) => {
const sorted = items.sort((a, b) => b.score - a.score);
return <ol>{sorted.map((i) => <li key={i.id}>{i.label}</li>)}</ol>;
};

The mutation is the bug. items.sort() mutates the array prop in place, which is a side effect during render and a Rules-of-React violation. The compiler skips this component and warns. It was wrong before the compiler ever looked at it.

Sometimes you can’t fix the violation right away, because you’re mid-migration or you’ve hit a confirmed compiler bug. For that there’s an escape hatch : the 'use no memo' directive at the start of a function body tells the compiler to skip that function. It silences the warning but ships the original, unoptimized, possibly still-buggy code; it fixes nothing. Reach for it only as a temporary measure, a TODO with a comment linking the issue you’re tracking, never as a permanent waiver.

You won’t always need DevTools to catch these. The compiler’s diagnostics ship as rules in eslint-plugin-react-hooks (version 7), so an in-render mutation or broken manual memoization lights up red in your editor as you type, before you ever open the browser.

Once the compiler is on, two tools confirm it works, and each answers a different question: did the compiler process this component, and does that component actually re-render?

The Memo ✨ badge answers the first. Open React DevTools, go to the Components panel, and look at a component’s name; if the compiler processed it, a Memo ✨ badge sits beside the name. It’s the same badge a hand-written React.memo produces, so compiler-inserted and hand-written memoization look identical.

The badge means the compiler processed this component. It does not mean the component never re-renders. A badged component still re-renders every cycle if a parent the compiler couldn’t optimize feeds it an unstable prop. The compiler stabilizes the references a component creates in its own body; it can’t reach above the component and fix churn a parent injects from outside. The badge describes the component, not its parent.

The Profiler answers the second: it records an interaction and reports which components rendered and why. The loop is short: record an interaction, find a component that rendered while its props held steady, and go audit it. You’ll learn the full Profiler later in the course; for now, hold on to that loop.

A missing badge is also a signal. If a component you expected the compiler to handle has no Memo ✨ badge in a compiled build, the compiler is skipping that file over a purity violation, so go audit it as in the previous section.

This walker asks the questions in order: badge first, then the Profiler, then locate the boundary. Walk it as if you’d just enabled the compiler and something looks off.

I turned on the compiler — is it working?

Turn the compiler on, reload the app, and it won’t feel ten times faster. That’s normal, and raw speed is the wrong way to measure the win.

For a typical web app, expect a modest improvement: fewer wasted re-renders, interactions that feel a touch snappier. Something like 5 to 15% fewer re-renders is reasonable, but it depends entirely on how much waste was there to begin with. An app with a real wasted-render problem has more to eliminate, so it gains more; an app that was already meticulously hand-memoized barely changes, since the compiler only does what those engineers already did.

The durable win is the memoization ceremony you delete: fewer lines to write, to read in review, and to maintain as the code changes. You also lose the gaps hand-memoization leaves behind, because the compiler never gets tired or forgets a dependency. Less code, and more correct code; performance is the bonus.

One question stays open: with the compiler carrying memoization for the whole app, does an engineer ever still reach for useMemo, useCallback, or memo by hand? A narrow surface remains where manual memoization earns its weight, and that’s the next lesson.