React Compiler: How to Adopt It Incrementally

React Compiler reached 1.0 in October 2025, and on an existing codebase the safe way to adopt it is not a global switch. Compile one directory through a Babel overrides block, or wrap the compiled output in a runtime feature flag, then widen the net once you have shipped a release with no regressions.

The reason for the caution is narrow and specific. The compiler is correct only to the extent that your components already follow the Rules of React. It moves the memoisation boundaries in your app, so any code that quietly depended on a reference changing between renders, or on one staying stable, changes behaviour.

What the compiler actually does to your code

It rewrites components to read and write a per-render cache. The installation docs show the shape of the output:

import { c as _c } from "react/compiler-runtime";

export default function MyApp() {
  const $ = _c(1);
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = <div>Hello World</div>;
    $[0] = t0;
  } else {
    t0 = $[0];
  }
  return t0;
}

That is useMemo, useCallback and React.memo applied by a compiler with a control-flow graph instead of by hand. It can memoise after an early return, which you cannot do with hooks.

The limits matter as much as the capability. React’s own introduction page is blunt about three of them: the compiler does not memoise plain functions outside components and hooks, it does not share a cache between two components doing the same expensive work, and it does nothing for initial render cost. If your problem is a slow first paint, this is the wrong tool.

For the upside, the only production numbers React has published are Meta’s. In the 1.0 announcement they report up to 12% faster initial loads and cross-page navigations on the Meta Quest Store, roughly 2.5x on certain interactions, and neutral memory. Your app is not the Quest Store, so treat that as a reason to measure rather than a number to quote.

How do you do incremental adoption of React Compiler?

There are three levers, and they differ mostly in blast radius.

The narrowest is annotation mode. Set compilationMode: 'annotation' and nothing compiles until you write "use memo" at the top of a component or hook:

// babel.config.js
module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', {
      compilationMode: 'annotation',
    }],
  ],
};
function TodoList({ todos }) {
  "use memo";

  const sortedTodos = todos.slice().sort();
  return <ul>{sortedTodos.map(todo => <TodoItem key={todo.id} todo={todo} />)}</ul>;
}

Next.js supports the same thing through next.config.ts, which is the version most teams will actually use:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  reactCompiler: {
    compilationMode: 'annotation',
  },
}

export default nextConfig

You still install babel-plugin-react-compiler as a dev dependency for that. Next.js runs an SWC pre-pass that works out which files contain JSX or hooks and only sends those through Babel, which keeps the build cost smaller than a naive Babel pipeline.

The middle lever is directory scoping with Babel’s overrides, which is what I would reach for first on a large app:

// babel.config.js
module.exports = {
  plugins: [],
  overrides: [
    {
      test: './src/settings/**/*.{js,jsx,ts,tsx}',
      plugins: ['babel-plugin-react-compiler'],
    },
  ],
};

Widening coverage is then a one-line change: add another glob to test. No directive litter in the components themselves, and the diff that expands the rollout is reviewable.

Which directory should go first?

Pick a leaf-heavy area with real test coverage and few effects. Settings screens, tables, forms that own their own state. Avoid whichever directory has the highest density of useEffect with object or array dependencies, because that is where the compiler’s different memoisation shape shows up as an effect firing when it did not before.

When is the runtime gate worth it?

The gating option compiles both versions and picks one at runtime from a function you supply:

// babel.config.js
module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', {
      gating: {
        source: 'ReactCompilerFeatureFlags',
        importSpecifierName: 'isCompilerEnabled',
      },
    }],
  ],
};
// ReactCompilerFeatureFlags.js
export function isCompilerEnabled() {
  return getFeatureFlag('react-compiler-enabled');
}

You pay for that in bundle size, since both code paths ship. It earns its keep in two situations: you want an A/B measurement to justify the work to whoever asked for it, or your release process is slow enough that a config flag is a meaningfully faster rollback than a deploy. On a site that redeploys in four minutes, directory scoping is simpler and cheaper.

Turn on the lint rules before you touch the build

This is the part to do first, and it costs nothing. The compiler’s static analysis now ships as ESLint rules in eslint-plugin-react-hooks, version 7.1.1 as of September 2026:

// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';

export default defineConfig([
  reactHooks.configs.flat.recommended,
]);

recommended gets you the stable set. recommended-latest adds the experimental compiler rules, which is worth trying on a branch to see the shape of the work ahead. The names tell you what they catch: set-state-in-render, set-state-in-effect, refs, immutability, purity, preserve-manual-memoization, incompatible-library.

Every violation these report is a bug that already exists in your app. The compiler does not create them, it makes them visible. Fixing them is useful whether or not you ever enable compilation, which makes this the one step with no downside.

What breaks, and how do you know it is the compiler?

Almost always the same category: code that relied on memoisation for correctness rather than for speed. The debugging guide lists the symptoms as effects over-firing, infinite render loops, and updates that stop arriving because some conditional was comparing references.

React’s diagnostic order is worth following literally. Add "use no memo" to the suspect component; if the problem disappears, compilation is involved. Then delete the manual useMemo, useCallback and memo from that component with the compiler off. If the bug is still there, you have a Rules of React violation, not a compiler bug.

function ProblematicComponent() {
  "use no memo";
  // ...
}

Treat that directive as a bookmark with a ticket attached, not a fix. Grep for it before every release. React DevTools puts a “Memo” badge next to compiled components, which is how you confirm one has come back under compilation after you remove the opt-out.

Should you delete your useMemo calls?

Not as part of the rollout, and probably not in one pass afterwards. React’s guidance is to leave existing memoisation in place or test carefully before removing it, because removing it changes what the compiler emits. The preserve-manual-memoization rule exists precisely because hand-written memoisation the compiler cannot preserve is a signal that something in that component is off.

New code is different. Write it without useMemo and useCallback, and add them back only when an effect dependency genuinely needs a stable reference.

React 17 and 18, and the build tools

The compiler supports React 17 and up through the target option, which defaults to '19':

{ target: '18' }

Anything below 19 also needs react-compiler-runtime in dependencies, not devDependencies, because it ships to the browser. Use strings, and no patch versions.

Build tooling is the rougher edge. @vitejs/plugin-react v6 dropped its Babel dependency, so the current Vite setup in the docs routes the compiler through @rolldown/plugin-babel:

import { defineConfig } from 'vite';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';

export default defineConfig({
  plugins: [
    react(),
    babel({ presets: [reactCompilerPreset()] }),
  ],
});

If you are already partway through a bundler change, our notes on migrating to Vite 8 and Rolldown cover what else moves at the same time. As of the 1.0 announcement, swc support is experimental, oxc support is in progress, and native Rolldown support is waiting on Rolldown’s own release. Until one of those lands, enabling the compiler means keeping a Babel pass in a toolchain that has spent three years removing them.

What I would do on a typical React app: enable the lint rules this week and fix what they surface, then scope the compiler to one directory for a release and watch your INP field data rather than a synthetic profile. Widen a directory at a time. Reach for the runtime gate only if someone needs an A/B number. The case for not enabling it at all is an app with thin test coverage and a lot of effects wired to object dependencies, where the honest first move is fixing that, not compiling it.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch