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.
What the compiler actually does
Section titled “What the compiler actually does”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: a plain component with a derived total and an inline onClick, no memoization written by hand. Beside it, a plain util with no JSX.
Next.js’s SWC pass picks only files with JSX or hooks. The plain util is skipped, so builds stay fast.
The React Compiler’s Babel plugin rewrites the body, inserting memo slots for the derived value and the callback.
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.
What gets auto-memoized
Section titled “What gets auto-memoized”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 foruseCallbackto 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 manualuseMemoyou 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.
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.
Enabling it in Next.js 16
Section titled “Enabling it in Next.js 16”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.
-
Install the compiler as a dev dependency.
Terminal window pnpm add -D babel-plugin-react-compilerThe 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.
-
Set
reactCompiler: truein 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.
Incremental adoption for legacy codebases
Section titled “Incremental adoption for legacy codebases”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.
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.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { reactCompiler: { compilationMode: 'annotation' },};
export default nextConfig;Migrating a large legacy app? Annotation mode compiles only functions that begin with the 'use memo' directive, so you validate the compiler on a small, audited subset first, then expand file by file. A component opts in with a 'use memo' string as the first statement of its body. Once enough of the codebase is verified, flip to full coverage.
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.
What the compiler will not do
Section titled “What the compiler will not do”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 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
Section titled “When the compiler skips a component”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.
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>;};Spread into a new array first and the sort touches a copy, not the prop. Render is pure again, so the compiler optimizes it and the Memo ✨ badge appears. The fix was a few characters.
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.
Verifying it’s working
Section titled “Verifying it’s working”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.
The compiler skips exactly the components it can’t prove are pure: an in-render mutation, a conditional hook call, a side-effectful initializer. Open those files, find the violation, and correct it rather than silencing the warning.
No badges anywhere means the compiler isn’t running.
Confirm reactCompiler: true is set in next.config.ts and that babel-plugin-react-compiler is installed as a dev dependency.
The compiler optimizes within a component, so it can’t fix churn injected from a parent above its visibility. Find the parent that builds a fresh prop every render and stabilize it there, or let the compiler handle that parent too.
The badge is the contract, the Profiler is the proof. Both check out, so the compiler is carrying your memoization.
Performance: what to actually expect
Section titled “Performance: what to actually expect”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.
External resources
Section titled “External resources”Paste a component and watch the compiler's output update live — proof that the transform is inspectable, not a black box.
The official React docs on what the compiler does, how it's configured, and how to debug it.
The Next.js 16 reference for the reactCompiler option, including annotation mode.
The React reference for the compiler's opt-in and opt-out directives.
Tony Alicea's deep dive into the AST, the intermediate language, and the cache-checking code the compiler emits.