Skip to content

Fix: React Compiler Not Working — ESLint Plugin, Babel Setup, Bail-Outs, and Vite/Next.js Config

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix React Compiler issues — eslint-plugin-react-compiler not flagging, babel-plugin-react-compiler not running, 'Function contains a code construct that prevents compilation', Next.js 15 config, and removing useMemo/useCallback safely.

The Error

You install React Compiler and nothing happens. No build error, no faster rendering, no lint warnings on bad code:

npm install -D babel-plugin-react-compiler eslint-plugin-react-compiler

Or it runs but bails out on most of your components:

[react-compiler] Function contains a code construct that prevents compilation:
mutating a value from a different scope at line 12:8

Or the ESLint plugin doesn’t flag a known violation, even though it’s installed:

// .eslintrc.json
{
  "plugins": ["react-compiler"]
  // No rules array — the plugin doesn't run.
}

Or after enabling the compiler your tests start failing with stale state:

Expected: "loading"
Received: "loaded"

Why This Happens

React Compiler is an opt-in build-time transform that auto-memoizes Components and Hooks. When enabled, it does what you used to do manually with useMemo, useCallback, and React.memo — but only on code that follows the Rules of React. Code that mutates props, reads stale closures, or hides state in module scope is “non-conforming” and gets skipped (bailed out) so it still runs correctly without compiler help.

Most failures map to one of:

  • Plugin installed but not wired up. The Babel plugin needs an entry in babel.config.js. The ESLint plugin needs extends or an explicit rules block.
  • Framework-specific setup. Next.js 15 has experimental.reactCompiler in next.config.js. Vite needs vite-plugin-react with the compiler option. Plain Babel + Webpack needs manual ordering.
  • Bail-outs are the design, not a bug. When the compiler can’t safely memoize, it does nothing. You only get the benefit on code that follows the rules. The error message names the line that broke compilation.
  • Tests changing behavior are usually catching real bugs that the manual memoization had masked. A component that “worked” because it re-rendered every parent update will see new behavior once the compiler memoizes it.

The reason “the compiler is on but nothing changed” is so common is that the compiler is conservative by design. It is a static analyzer first and an optimizer second — if it cannot prove a function is a Component or Hook that follows the Rules of React, it skips it silently. Skipping is correct: the alternative would be miscompiling production code. But it also means the only way to know whether your code was actually rewritten is to read the build output or use the ESLint plugin as a proxy. Teams that install only the Babel plugin and assume “no error means compiled” routinely discover months later that bail-outs were happening everywhere.

The other structural surprise is that React Compiler is not part of React itself. It is a separate Babel plugin that runs at build time on each file. That has two consequences: it must be wired into every bundler you use (Next.js, Vite, Remix, Storybook, Jest, your custom Webpack config), and it can be at a different version from react and react-dom in your node_modules. A mismatch between react 18 and a babel-plugin-react-compiler build targeting 19 produces runtime errors with confusing stack traces, which is why the target: "18" and target: "19" plugin options exist.

Fix 1: Wire Up the Babel Plugin

Add the plugin to your Babel config. It must run before other React transforms — presets run last, so put the compiler in plugins:

// babel.config.js
module.exports = {
  plugins: [
    ["babel-plugin-react-compiler", {
      // Optional compiler options — see the docs for the full list.
      target: "19",  // or "18" if you're still on React 18
    }],
  ],
  presets: ["@babel/preset-env", "@babel/preset-react"],
};

For React 18 users — yes, the compiler works on React 18, but you need the react-compiler-runtime package:

npm install react-compiler-runtime

Then set target: "18" in the plugin options.

Verify it’s running by adding a deliberate Rules of React violation and watching for the bail-out message in your build logs:

function Buggy({ items }) {
  items.push({}); // Mutating a prop — compiler will bail out here.
  return <div>{items.length}</div>;
}

The build output should include a [react-compiler] log line naming the bail-out reason.

Fix 2: Wire Up the ESLint Plugin

Install and enable in your ESLint config. The plugin ships a recommended config — extend it:

// eslint.config.js (flat config)
import reactCompiler from "eslint-plugin-react-compiler";

export default [
  {
    plugins: { "react-compiler": reactCompiler },
    rules: {
      "react-compiler/react-compiler": "error",
    },
  },
];

Or with the legacy .eslintrc.json:

{
  "plugins": ["react-compiler"],
  "rules": {
    "react-compiler/react-compiler": "error"
  }
}

Common Mistake: Listing "react-compiler" under plugins without adding the rule to rules. ESLint loads the plugin but doesn’t run any of its checks. Add the rule explicitly.

The plugin flags the same patterns the compiler bails out on — mutating props, conditional hooks, reading state in render bodies that should be in effects. Fix the warnings and your code compiles automatically.

Fix 3: Next.js 15 Setup

Next.js 15 has first-class React Compiler support behind a config flag:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    reactCompiler: true,
  },
};

module.exports = nextConfig;

For finer control, pass an options object:

experimental: {
  reactCompiler: {
    compilationMode: "annotation",  // only compile components marked "use memo"
  },
},

compilationMode: "annotation" is gold for incremental adoption — opt individual components in by adding the "use memo" directive at the top:

"use memo";

export default function ExpensiveList({ items }) {
  return items.map(...);
}

Without the directive, the component is untouched. This lets you roll out the compiler one file at a time.

Pro Tip: Combine compilationMode: "annotation" with a CI lint that compares before/after bundle sizes per route. You’ll catch components where the compiler accidentally bloats output (rare but real).

Fix 4: Vite Setup

@vitejs/plugin-react accepts a Babel plugin list in its options:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [
          ["babel-plugin-react-compiler", { target: "19" }],
        ],
      },
    }),
  ],
});

If you use @vitejs/plugin-react-swc (the SWC variant), the compiler doesn’t run by default — it’s a Babel pass, and SWC doesn’t execute Babel plugins. Either swap to @vitejs/plugin-react (Babel variant) for compiler builds, or check the React Compiler docs for the current SWC-compatible bridge plugin (names change as the ecosystem matures).

Note: Don’t try to run the compiler twice. If both babel.config.js and vite.config.ts list it, the second pass sees compiled output and breaks. Pick one.

Fix 5: Read Bail-Out Messages and Fix the Rules Violation

When you see a bail-out:

[react-compiler] Function contains a code construct that prevents compilation:
mutating a value from a different scope at line 12:8

The compiler is telling you exactly what’s wrong. Common patterns and fixes:

Mutating a prop:

// Bail-out:
function List({ items }) {
  items.sort();  // Mutates the prop.
  return items.map(...);
}

// Fix:
function List({ items }) {
  const sorted = [...items].sort();
  return sorted.map(...);
}

Reading state outside a hook:

// Bail-out:
let cache = {};

function MyComponent({ id }) {
  if (!cache[id]) cache[id] = compute(id);
  return <div>{cache[id]}</div>;
}

// Fix: cache in a ref or context, not module scope.
function MyComponent({ id }) {
  const cacheRef = useRef({});
  if (!cacheRef.current[id]) cacheRef.current[id] = compute(id);
  return <div>{cacheRef.current[id]}</div>;
}

Conditional hooks:

// Bail-out (also a Rules of Hooks violation):
function Auth({ user }) {
  if (user) {
    useEffect(() => track(user.id), [user.id]);
  }
}

// Fix:
function Auth({ user }) {
  useEffect(() => {
    if (user) track(user.id);
  }, [user]);
}

Fix 6: Remove Redundant useMemo / useCallback (Carefully)

The whole point of React Compiler is that you no longer need to hand-write useMemo/useCallback everywhere. But removing them in one big PR is risky — some of your manual memoizations were load-bearing for non-React reasons (ref equality for downstream libraries, stable identity for useEffect deps).

The safe migration order:

  1. Turn on the compiler with compilationMode: "annotation".
  2. Add "use memo" to one component at a time.
  3. Run your tests and check production telemetry for that route.
  4. Once stable, remove the manual useMemo/useCallback in that file.
  5. Move on to the next file.

If you switch to compilationMode: "infer" (compile everything), do it after a few weeks of partial adoption so you have a base of trust.

Common Mistake: Assuming the compiler will optimize across component boundaries. It memoizes within a component. Passing a freshly-created object to a child component still triggers a re-render unless the child is also compiled.

Fix 7: HMR / Fast Refresh Behavior Changes

Fast Refresh patches compiled components in place during dev. If a component’s memoization signature changes (you add a new hook, change a closure shape), Fast Refresh sometimes preserves stale memo cache and the component renders with old data.

Fix: force a full reload when you see weird state-preservation in dev. Most editors have a “restart dev server” command. In the browser, hard-refresh.

The compiler itself doesn’t break Fast Refresh — but the interaction between memoization changes and HMR is delicate. If you can reproduce stale state in dev but not in production, this is the likely cause.

Fix 8: Tests Behaving Differently After Compiler

Your tests pass before the compiler, fail after:

Expected: "loading"
Received: "loaded"

Two common causes:

  • React Testing Library + act warnings. Memoized components defer some work to the next microtask. Wrap async state changes in await waitFor(...).
  • Tests that depend on re-render counts. A test that asserts mockFn.toHaveBeenCalledTimes(3) will fail if the compiler reduces re-renders to 2. Rewrite the assertion in terms of behavior (final DOM state) rather than render count.

Don’t disable the compiler in tests to make them pass — that hides the real production behavior. Fix the test instead.

Version History: React Compiler Release Milestones

React Compiler has moved fast in the last 18 months. The version you read about online may not match the one in your package.json.

  • Internal use at Meta (2022–2023). The compiler shipped in Instagram and Facebook web before public release. Most of the public bail-out messages and rule semantics were finalized during this period; the public release inherited them with little change.
  • Public beta (May 2024). First open babel-plugin-react-compiler and eslint-plugin-react-compiler packages. Defaults were aggressive (compile everything that looked like a Component). Many early-adopter bug reports came from this build mis-memoizing component identity, fixed in subsequent prereleases.
  • Release candidate (Dec 2024). Behavior stabilized. compilationMode: "annotation" was promoted as the recommended incremental adoption path. The ESLint rule set was finalized so warnings now match exactly what would cause a bail-out.
  • React 19 + Compiler synergy (Dec 2024). React 19 GA added internals the compiler relies on for tighter memoization, removing the react-compiler-runtime dependency on React 18. Code compiled for 19 runs faster than the same code compiled for 18 because the runtime shim is gone. React 18 users can still benefit but pay a small runtime cost.
  • 2025 plugin line. Continuous improvement to bail-out reporting, support for the "use memo" and "use no memo" directives (with opt-out semantics that flipped between prereleases), and the swc-plugin-react-compiler experimental SWC bridge so projects on @vitejs/plugin-react-swc can stop swapping back to Babel.
  • Auto vs opt-in defaults. Through the beta and RC, the framework integration defaults swung between “compile all files” and “compile only annotated files.” Next.js 15 stable currently defaults to compile-all when experimental.reactCompiler: true is set. Consult the changelog for your exact Next.js minor — the default has flipped at least twice.

Two practical implications: first, if you read a tutorial dated before mid-2024, the directive name, the plugin options, and the runtime requirements are likely stale. Cross-check against the current README before copying. Second, when bumping babel-plugin-react-compiler minor versions, re-run your ESLint suite. The rule set evolves alongside the compiler — new bail-out warnings often appear on code that compiled cleanly on the previous version.

Still Not Working?

A few less-obvious failures:

  • The compiler runs but bundle size goes up. The auto-generated memo cache has overhead. For tiny components, the manual version was cheaper. Use compilationMode: "annotation" to skip components that don’t need it.
  • TypeScript can’t find the directive. "use memo" is a string literal at the top of the file. It’s not a TS thing — TS ignores it.
  • react-compiler-runtime not found. You’re on React 18 and forgot to install react-compiler-runtime. Required on 18, not needed on 19+.
  • Errors on older Node versions. The plugin requires Node 18+. Upgrade Node before chasing imaginary config issues.
  • Storybook stories don’t compile. Storybook uses its own Babel config. Add the compiler plugin to .storybook/babel.config.js (or override via babelDefault in .storybook/main.ts).
  • JSX runtime errors after enabling. You’re mixing the classic JSX runtime with the automatic one. The compiler expects automatic — set "jsx": "react-jsx" in tsconfig.json and runtime: "automatic" in your Babel preset.
  • Cannot find module 'babel-plugin-react-compiler' in CI but works locally. Lockfile drift — re-run npm install and commit package-lock.json. Or it’s a devDependency that your CI install script skips.
  • The “use memo” directive isn’t recognized. Older compiler versions used “use no memo” with opposite semantics. Check your babel-plugin-react-compiler version against the directive convention in the docs you’re following.
  • Production build inlines _c cache variables differently from dev. Minifiers can rename the compiler’s internal cache identifier, which is harmless but confuses source-map debugging. Use the React DevTools Profiler instead of reading minified stacks.
  • A specific file silently fails to compile only on Windows. Path-length limits in node-gyp-compiled dependencies inside babel-plugin-react-compiler sometimes trip on long Windows paths. Move the project closer to the drive root and re-run npm install.
  • Compiler “works” but ref equality changes between renders for memoized callbacks. The compiler memoizes per logical scope. If your callback closes over a value the compiler considers unstable (e.g. a reference from an uncompiled parent), the resulting function is recreated. Verify with React DevTools’ “why did this render” feature.

For related React performance and tooling issues, see React useEffect runs twice, React too many re-renders, React memo not working, and Vite HMR connection lost.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles