React 19 Compiler: What It Does, What It Doesn't, and When to Enable It

#react 19 compiler
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

The React 19 compiler has sat in the "we should look at this" pile for most frontend teams since it shipped as stable. Formerly known as React Forget, it takes over the job that useMemo and useCallback used to do by hand: deciding which values and components can be reused between renders. The pitch is that you delete boilerplate and get a faster app for free. Part of that is true and part of it is marketing. This post goes through what the compiler does, where it stops, how to switch it on, and how to roll it out on a codebase that is too big to break.

What the compiler changes

React re-renders a component whenever its parent re-renders, unless something tells it not to. Before the compiler, that something was you. You wrapped components in React.memo, wrapped callbacks in useCallback so the memoized child would see a stable reference, and wrapped expensive computations in useMemo. Every one of those wrappers came with a dependency array that you had to keep correct by hand, and a missing dependency was the classic source of stale-closure bugs.

The compiler is a Babel plugin that runs at build time. It analyzes each component and hook, works out which values depend on which inputs, and rewrites the code so that each derived value is cached and only recomputed when its inputs change. In practice, the output looks like a component with a cache array of slots that the compiler fills and checks. If props.items has not changed, the filtered list computed from it is reused. If the onClick handler only closes over stable values, the same function reference is passed to the child on every render, and a memoized child skips its render.

The important part is that the compiler works from React's rules. It assumes props and state are immutable, that hooks are called unconditionally, and that render functions are pure. Code that follows those rules gets optimized. Code that does not gets skipped, and this is the part most write-ups gloss over.

What it does not do

The compiler is conservative on purpose. When it cannot prove that memoizing a value is safe, it leaves the component alone rather than risk changing behavior. That means the components most likely to be skipped are exactly the ones that were written carelessly, which are usually the ones you wanted help with.

Mutation during render is the most common reason a component gets skipped. Pushing into an array that was created from props, or mutating an object after it has been passed into JSX, breaks the compiler's assumptions. It will not optimize that component, and it will not warn you unless you have the lint rule installed (more on that below).

External mutable state is the other big one. If a component reads from a module-level variable, a global singleton, or a store that does not go through useSyncExternalStore or a hook, the compiler has no way to know when that value changed. It cannot memoize against something it cannot see.

Then there is the limit that no amount of tooling fixes. The compiler can tell that a value depends on props.items. It cannot tell that only the length of the list matters for a given branch, or that two objects with different references are equal in every way your business logic cares about. That kind of optimization needs knowledge of your data model, and no build tool has it. If a parent hands a child a freshly built object on every render, the compiler will happily memoize the child against that object, and the child will still re-render every time because the input really did change by reference. Fixing that is still a design decision: lift the object creation out, pass primitives, or restructure the component.

Finally, the compiler does not replace performance work at the data layer. A component that re-renders in 2 ms instead of 6 ms does not help when the page waits 800 ms for an over-fetching API. We see this frequently in performance audits: teams enable the compiler expecting a visible change and get nothing because the render phase was never the bottleneck.

Enabling it in Next.js and Vite

In Next.js, the compiler is a config flag. On Next.js 15 it lives under experimental:

// next.config.js
const nextConfig = {
  experimental: {
    reactCompiler: true,
  },
};
module.exports = nextConfig;

On Next.js 16 the option moved out of experimental and is simply reactCompiler: true. Either way, install babel-plugin-react-compiler as a dev dependency first. Note that enabling it pulls Babel into the build for the affected files, which slows down compilation compared to the SWC-only path. On a mid-sized app this is noticeable in dev startup and CI build time, so measure it before a full rollout.

In Vite, pass the plugin through the React plugin's Babel config:

// vite.config.js
import react from '@vitejs/plugin-react';

export default {
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-react-compiler', {}]],
      },
    }),
  ],
};

The compiler targets React 19 by default. It can run against React 17 and 18 with the react-compiler-runtime package and a target option, which matters if you want the compiler before you finish the React 19 upgrade itself. For most teams we recommend doing the upgrade first. Two large changes at once make it hard to attribute a regression.

The lint rule that matters

The compiler ships with an ESLint integration that reports the code patterns it refuses to optimize. Originally this was a separate package, eslint-plugin-react-compiler. It has since been folded into eslint-plugin-react-hooks, so a current version of that plugin with the recommended config gives you the compiler diagnostics without an extra install.

Run it before you enable the compiler, not after. The output is a list of components the compiler will skip and the reason for each: mutation of a value used in render, a hook called conditionally, a ref accessed during render, and so on. On a codebase that has never been linted for the rules of hooks, this list is long. That is not a reason to give up. Every item on it was already a latent bug or a place where manual memoization would have silently done the wrong thing. Fixing the top offenders is usually a week of focused work on a codebase of a few hundred components, and it improves the app whether or not you turn the compiler on afterwards.

Verifying what the compiler did

You should not take the compiler's word for it. The React DevTools extension marks compiled components with a small "Memo" badge in the component tree, so you can see at a glance whether a component was optimized or skipped. The Profiler tab is where the real verification happens: record an interaction, look at the flame chart, and compare the number of components that rendered before and after enabling the compiler. If a component still renders on every keystroke in an unrelated input, either the compiler skipped it or its inputs really do change, and the profiler tells you which by showing what changed.

A useful workflow: pick a few interactions that users complain about, record them with the profiler on the current build, save the profiles, then enable the compiler for the relevant directory and record the same interactions again. Diff the render counts and the commit durations. Anything the compiler improved will show up as fewer renders. Anything that did not improve points either at a skipped component (run the lint rule on it) or at a problem the compiler could never have solved.

What to expect from the numbers

Be careful with claims about speedups. The size of the gain depends almost entirely on how much manual memoization the codebase already had and how many components were doing wasted work. A codebase where a diligent team already wrapped everything in useMemo and useCallback will see very little change in runtime, because the compiler is doing what the developers already did. The win there is that you can delete most of that manual code and stop maintaining dependency arrays.

Codebases with almost no manual memoization and a few large lists or forms tend to see the clearest improvement, typically in interactions where a parent re-render used to cascade into dozens of children that now bail out. Input latency on forms and scroll performance on data tables are the places where the difference is easiest to feel and easiest to measure with the profiler.

One effect that rarely gets mentioned: memoization is not free. Each cached slot costs memory and a comparison on every render. For a small component tree with cheap renders, the compiler can produce code that is marginally slower than the naive version. That is rarely measurable, but it is a reason to profile rather than assume.

Rolling it out on a large application

The compiler is designed for incremental adoption, and on a large app you should use that. The plugin accepts a sources option, a function that receives a file path and returns whether that file should be compiled. In Next.js you can pass compiler options through the same config flag, so limiting compilation to a single directory looks like this:

const nextConfig = {
  experimental: {
    reactCompiler: {
      compilationMode: 'annotation',
    },
  },
};

In annotation mode the compiler only touches components that carry a "use memo" directive at the top of the function body, which is the safest way to start. The inverse also exists: in the default mode, a "use no memo" directive opts a component out. That is the escape hatch for a component that behaves differently after compilation while you work out why.

A sensible order is to run the lint rule across the whole codebase first and fix everything it flags in the area you plan to start with, then enable compilation for that directory or with annotations, then verify with the profiler and a full pass of your end-to-end tests, and only then widen the scope. Teams that flip the global switch with a long lint backlog tend to end up with a few subtle behavior changes in components that quietly relied on mutation, and those are miserable to track down afterwards.

Watch out for two classes of code specifically. Components that mutate refs during render, or that read ref.current to decide what to render, can change behavior under the compiler. And any component that depended on a re-render happening as a side effect of a parent update, rather than because its own inputs changed, will stop re-rendering. That second case is almost always a bug in the original code, but it is a bug the app was working around, and the compiler removes the workaround.

Should you enable it?

If you are starting a new Next.js or Vite project on React 19, yes, from day one. The lint rule keeps the codebase honest and you never accumulate manual memoization you later have to remove.

If you have an existing app with a healthy lint setup and a team that already follows the rules of hooks, enable it directory by directory over a few sprints and measure each step. The main win is code you get to delete.

If you have an existing app that has grown without much discipline, or one that a previous team or an AI assistant generated at speed, do the lint pass first and treat the compiler as a later reward. The lint output alone will tell you a lot about the state of the codebase. If that list is long enough to be daunting, that is worth a conversation about modernization before it is worth a conversation about the compiler.

We help teams make these calls as part of our web application development and code review work. If you want a second opinion on whether the compiler is worth the effort for your codebase, or someone to run the profiler comparison and lint triage with you, write to hello@wolf-tech.io or see wolf-tech.io.