Skip to content
Chapter 26Lesson 2

The React Compiler

Meet the React Compiler, the build-time tool in Next.js 16 that inserts your memoization for you and ends the era of hand-written useMemo and useCallback.

Open a React codebase from 2024 and you’ll find a recurring texture. Every derived value sits inside a useMemo. Every event handler is wrapped in a useCallback. Every leaf component is exported through React.memo. The developers who wrote it weren’t careless. They were defending against exactly the waste you spent the last few chapters learning to see: a parent re-renders, props flow down, Object.is says a freshly-built object prop is a new reference, and a whole subtree re-renders for nothing.

The frustrating part is that after all that wrapping, the app still re-renders too much. The memoization has gaps, because doing it perfectly by hand means tracking every dependency of every value across every render, and humans miss some. One stray inline style={{ padding: 8 }} on a prop slips through and re-renders a subtree the memo was supposed to protect. So the cost is doubled. The engineer writes this defensive wrapping, reviews it in every pull request, and maintains it forever as the code changes, and even then it still doesn’t fully work.

The React Compiler ends that era. Stable since its 1.0 release in October 2025 and a first-class config option in Next.js 16, it reads your components at build time and inserts that memoization for you, at the boundaries that matter, without the gaps. By the end of this lesson you’ll be able to 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 chapters on the render model taught you why renders happen: the three triggers, Object.is on props, identity churn from inline literals, and how to defend against waste by hand. This is where that manual defense gets handed off. The shift to internalize is that 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.

The two words doing the most work there are build-time. The compiler runs once, when your project is built, the same moment your TypeScript becomes JavaScript and your modules get bundled. It is not a runtime engine that watches your app and makes decisions while users click around. There is no React Compiler library shipping in your production bundle, no scheduler, no extra layer between your components and the screen.

That matters because of what it implies about the output. The compiler rewrites your source into ordinary memoized React, the same code an expert would have written if they’d tracked every dependency perfectly. When the build finishes, what ships is plain React that happens to be optimally memoized, behaving exactly as if a careful human had done the wrapping. The natural reaction when you hear “a compiler optimizes my code automatically” is distrust, since you don’t want some black box rewriting what you wrote. But the transform isn’t a black box. It’s deterministic, it’s inspectable, and it produces the same kind of code you already know how to read. You opt into it deliberately, and you understand exactly what changed.

How does it decide what to memoize? It uses 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. You don’t need to know the compiler’s internals to use it well. You only need its shape, which is the same shape useMemo always had: when inputs change, recompute; when inputs are stable, reuse the previous reference.

The following diagram traces one small component through the build. Scrub through the steps to watch 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 that’s optimally memoized, and no compiler runs at runtime.

Two things are worth carrying forward from that sequence. First, the selection step: Next.js uses its SWC pipeline to pick only the files that contain JSX or hooks, so a plain utility module never gets handed to the compiler and your builds don’t pay to analyze code that has nothing to memoize. Second, the transform produces ordinary output. Nothing in step 4 is a runtime engine, which is the central point.

The abstract claim, “it inserts the memoization you’d have written,” gets clearer once you map it onto the specific traps you already know. Every item the compiler memoizes is a render-waste pattern you were taught to watch for in the chapters on the render model and the built-in hooks. Read this less as a list of new features and more as a list of old worries the compiler now removes.

  • Derived values computed during render. The kind of in-render calculation 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 you no longer have to re-derive it on every render by hand.
  • Object and array literals passed as props. <Sidebar config={{ theme, size }} /> is the canonical referential identity trap. A fresh object literal every render means the child sees a new reference and re-renders every time. The compiler stabilizes that reference when its contents haven’t changed.
  • Callbacks defined inside the component. <Row onClick={() => deleteItem(id)} /> gets a stable identity across renders as long as the values it captures don’t change, so you no longer reach for useCallback to keep it stable.
  • Provider values. <ThemeContext value={{ user, theme }}> is the exact re-render storm you saw with context: an unstable provider value re-renders every consumer on every parent render. The compiler stabilizes that value for you, which removes the manual useMemo you used to wrap around every provider value.
  • JSX subtrees. A piece of the tree that doesn’t depend on what changed gets reused instead of re-rendered.

That last group is the point of the whole exercise, and it’s easier to see than to read. In the following widget, an App holds a counter and renders a Sidebar that receives an inline config={{ ... }} object prop. Switch between the two tabs and bump the counter in each.

Bumping a counter in App

The compiler’s win is the box that stops lighting up. In the first tab, bumping an unrelated counter re-renders Sidebar, because the inline config object is a brand-new reference every render. Stabilizing that reference is exactly the kind of auto-memoization the compiler handles. In the second tab, the compiler has stabilized the reference, so Sidebar sees the same config it saw last time, Object.is agrees, and the subtree is skipped. You didn’t write a line of useMemo to get there.

Turning the compiler on is a deliberate choice you make once, and then you know exactly what changed about your build. It takes two steps.

  1. Install the compiler as a dev dependency.

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

    The name raises an obvious question: why a Babel plugin, when Next.js builds with Rust-based SWC and not Babel? Because the React Compiler itself is a Babel plugin, but Next.js doesn’t run Babel across your whole project. It wraps the compiler in a custom SWC optimization that hands it only the files containing JSX or hooks. You get the compiler’s analysis on exactly the files that need it, and your 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;

That’s the entire wiring. There is no per-file annotation and no marking components as “compile this one”: reactCompiler: true turns on full coverage across the whole app. That is the right default for a new project. For a SaaS app started in 2026, you turn the compiler on from day one and let it carry memoization for the entire codebase. The habit of wrapping things in useMemo and useCallback as you write them is the one you’re now dropping.

There’s a second mode worth recognizing, even though you won’t reach for it on a greenfield project. It exists for one situation: migrating a large, existing codebase onto the compiler gradually instead of all at once.

In the following comparison, the first tab is the default you just wired up. The second is annotation mode, the migration path.

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 in the app, with no opt-in per file. This is the senior default, so skip the rest of this section unless you’re migrating an old codebase.

The workflow for carrying out that file-by-file migration is its own topic, covered in the next lesson: what order to clean things up in, and how to handle the residual hand-written memoization. Here, you just need to know that annotation mode exists and what it’s for. It’s the on-ramp for an old codebase, not something you configure on a new one.

The compiler has a narrow, well-defined job: memoization. Pinning down its boundaries keeps you from expecting powers it doesn’t have, which is the usual source of confusion when something doesn’t behave the way you imagined.

It does not rewrite your effects. useEffect and its dependency array are untouched. The effect contract holds in full: synchronize with an external system, clean up, and list your reactive dependencies. The compiler doesn’t move logic into or out of effects.

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

It does not eliminate dependency arrays. You still write the dependencies for your effects. The compiler memoizes values inside render, and it doesn’t infer away the dependency list of a useEffect.

It does not memoize impure code. This is the hinge into the next section. If the compiler’s 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 pieces of what you’ve already learned slot in cleanly here rather than conflicting. 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. Together they cover the two halves of the effect problem, with no overlap. Refs fit just as cleanly. React 19 made ref a normal prop, so there’s no forwardRef anymore, and the compiler reads a component that takes ref from its props transparently, like any other prop.

Before moving on, draw the boundary yourself by sorting each item into 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.

Here is where a junior and an experienced engineer part ways. When the compiler skips a component and warns, a junior reads it as the compiler broke my component. An experienced engineer reads the same warning as the compiler found my bug. The compiler reports problems, it doesn’t cause them.

Think about what a skip actually reveals. A codebase that “worked” before the compiler often only worked because manual memoization happened to paper over an impurity. A component that quietly mutates one of its props, an initializer that pushes a value onto a module-level array, a render that calls a function with a side effect: these are latent bugs. They were always wrong, they just hadn’t surfaced yet. When the compiler’s analysis hits one, it refuses to optimize that component and emits a warning. It isn’t failing, it’s pointing at the thing that was already broken.

This connects directly to the purity contract from the render-model chapter, where you learned that a lot of React’s machinery only works when render stays pure. The compiler is one of those pieces: it optimizes only the components it can prove pure. The fix is never to silence the message, the fix is to correct the violation. The following before-and-after shows the smallest version of this, with a violation you can already recognize as impure.

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 genuinely can’t fix the violation in the moment, 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. Reach for it only as a temporary measure while you fix the underlying problem, never as a permanent waiver. Watch what it actually does: 'use no memo' silences the warning but ships the original behavior, the unoptimized and possibly still-buggy code. It does not fix anything. Treat it as a TODO with a comment linking the issue you’re tracking, not as a resolution.

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

You turned it on, and now you confirm it. There are two tools, and they answer two different questions. Knowing which tool answers which question is what keeps the check quick.

The first tool is the Memo ✨ badge, which tells you the compiler processed a component. Open React DevTools, go to the Components panel, and look at a component’s name. If the compiler processed it, you’ll see a Memo ✨ badge next to the name. It’s the very same badge a hand-written React.memo produces, and the fact that you can’t tell compiler-inserted memoization from hand-written memoization is the whole point.

There is one nuance here that trips up almost everyone. The badge means the compiler processed this component. It does not mean this component never re-renders. A component can carry the Memo ✨ badge and still re-render on every cycle, if an unstable reference is flowing into it as a prop from a parent that the compiler didn’t, or couldn’t, optimize. Picture it through the widget from earlier: the compiler stabilizes the references a component owns, the ones created inside its own body. It cannot reach above its own visibility and fix churn that a parent is injecting from the outside. The badge is a statement about the component, not a guarantee about its parent.

The second tool is the Profiler , which proves whether a component actually re-renders. It records an interaction and shows you which components rendered and why. The loop is: record an interaction, look for a component that rendered when its props didn’t change, and go audit that component. You’ll meet the Profiler in full later in the course, with flame graphs, render timings, and the complete read-and-record workflow. For now, recognize it as the tool that confirms re-render behavior, and remember the shape of that loop.

A missing badge isn’t a mystery either; it’s a signal. If a component you expected the compiler to handle has no Memo ✨ badge in a compiled build, that file is being skipped, which means a purity violation. You go audit it, exactly as in the previous section.

The following walker follows the order an experienced engineer asks these questions: 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?

One more expectation is worth setting straight, because it’s the source of a predictable letdown. You turn the compiler on, you reload the app, and it doesn’t feel ten times faster. That’s normal, and chasing raw speed is the wrong way to measure the win.

For a typical SaaS app, expect a modest baseline improvement: fewer wasted re-renders, interactions that feel a touch snappier. Something in the range of 5 to 15% fewer re-renders is a reasonable expectation, though it’s a range, not a promise, and it depends entirely on how much waste was there to begin with. An app that had a measurable wasted-render problem will see a larger win, because there’s more to eliminate. An app that was already meticulously hand-memoized will barely change at runtime, since the compiler is just doing what the humans already did.

So if not speed, what’s the prize? It’s the removal of the memoization ceremony. The durable, everyday win is fewer lines to write, fewer lines to read in code review, and fewer lines to maintain as the code changes. On top of that, you lose the gaps that hand-memoization always leaves, because the compiler doesn’t get tired or forget a dependency. The reward is less code that is also more correct. Performance is a bonus on top.

That leaves one honest question open. If the compiler is now the default and carries memoization for the whole app, is there ever a moment an engineer still reaches for useMemo, useCallback, or memo by hand? There is. A narrow, specific surface remains where manual memoization still earns its weight, and that surface is the entire subject of the next lesson.