Skip to content

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

FixDevs ·

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.

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.

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.

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