AniUI Academy

The React Compiler

What the React Compiler actually does, how it changes the useMemo/useCallback/React.memo mental model, how to adopt it into an existing app, and the "Rules of React" that decide whether it can optimize a given component.

10 min read

Everything the last two lessons covered — React.memo, useMemo, useCallback — was manual memoization. You, the author, decide which objects need a stable reference, which callbacks need wrapping, which children need skipping. The React Compiler is a build-time tool that does that work for you, automatically, on code that follows a short list of rules.

What the compiler is (and isn't)

It is not a runtime: nothing new runs in the browser. It isn't a replacement for React, and it doesn't change what hooks do. It is a Babel-style transform that runs during your build and rewrites your component code to insert the memoization you would otherwise write by hand.

Conceptually, it turns this:

function Dashboard({ user }) {
  const title = `Welcome, ${user.name}`;
  const onSave = (patch) => saveUser(user.id, patch);
  return <ProfileForm title={title} onSave={onSave} />;
}

into roughly this:

function Dashboard({ user }) {
  // Pseudocode — the real output uses a compact runtime helper.
  const title = useCachedValue(() => `Welcome, ${user.name}`, [user.name]);
  const onSave = useCachedValue(
    () => (patch) => saveUser(user.id, patch),
    [user.id]
  );
  return <ProfileForm title={title} onSave={onSave} />;
}

The title string and the onSave function keep the same reference across renders as long as their inputs haven't changed, so ProfileForm below — wrapped or not — doesn't re-render on identity changes alone. The author wrote the component as if memoization didn't exist; the compiler added it where it was safe.

What this changes about the previous lessons

The previous lessons' rules still describe the underlying model: shallow prop comparison, referential identity, the dependency array. What changes is how much of it you write by hand.

  • useMemo / useCallback for performance: mostly unnecessary. The compiler caches values and callbacks that are safe to cache.
  • useMemo / useCallback for correctness: still write them. If an effect's dependency array reads a value, you want that value's identity to be stable in a way you control; the compiler's cache is an implementation detail, not a public API.
  • React.memo: mostly unnecessary. The compiler auto-memoizes component outputs under the same shallow-props-equal rule. Keeping React.memo around as belt-and-braces is harmless but noisy.
  • The dependency array: still real, still your responsibility. The compiler helps with identity stability, not with the lint rule.

The Rules of React

The compiler can only cache code it understands. "Understands" here means "can prove that re-running the same inputs produces the same outputs." That's a stronger claim than the function-type checker makes, so there's a short list of rules every component the compiler touches has to follow:

  1. Render is a pure function of props and state. No mutation of props, no mutation of state during render, no writing to refs during render.
  2. Hooks are called in the same order every render. No conditional useState / useEffect; no hooks inside loops.
  3. Values aren't mutated after being handed to React. If you pass an object as a prop, don't mutate it later from another component.
  4. Side effects live in effects or event handlers, not in render.

These are the same rules React has always had — the hooks lint rule ("Rules of Hooks") is a subset — but the compiler enforces them with teeth. Code that breaks them produces a react-compiler/* lint warning and the compiler bails out on that component: it leaves the code alone and surfaces the violation so you can fix it.

Installing and turning it on

The compiler ships as a Babel plugin. The install and enablement is the same for a Vite app, a Create-React-App port, or a Next.js project — the build config file differs, but the plugin is the same.

npm install --save-dev babel-plugin-react-compiler
npm install --save-dev eslint-plugin-react-compiler

Add the Babel plugin to your build config:

// Vite (vite.config.js)
import react from "@vitejs/plugin-react";
export default {
  plugins: [
    react({
      babel: { plugins: ["babel-plugin-react-compiler"] },
    }),
  ],
};

Add the ESLint plugin to your ESLint config so rule violations get caught in your editor, not just at build time:

// eslint.config.js
import reactCompiler from "eslint-plugin-react-compiler";
export default [
  {
    plugins: { "react-compiler": reactCompiler },
    rules: { "react-compiler/react-compiler": "error" },
  },
];

Restart the dev server. React DevTools will start showing a Memo ✨ badge on components the compiler successfully optimized; components it bailed on don't get the badge, and the lint rule will show you why.

Adopting it on an existing app

The realistic path on a non-trivial codebase is incremental:

  1. Turn on the ESLint rule first, without the Babel plugin. Fix the rule violations it surfaces. These are latent bugs — mutation during render, conditional hooks, refs written from render — that happened to work because the pre-compiler code re-rendered often enough to hide them. Fix them anyway; the app is more correct for it.
  2. Then turn on the Babel plugin. Smoke-test the critical flows. Most regressions trace back to rule violations the ESLint rule couldn't statically catch — usually a component in a third-party library that mutates a prop you passed it. Either pin the compiler out of that file with "use no memo" or stop passing a mutable reference.
  3. Delete now-dead useMemo / useCallback calls whose only purpose was keeping a reference stable. You don't have to do this to benefit from the compiler — redundant memoization still works — but code reviewers thank you.

The one escape hatch is a "use no memo" directive at the top of a component function:

function LegacyHook() {
  "use no memo";
  // The compiler leaves this function alone.
  ...
}

This is for genuine exceptions — an experimental hook with intentional mutation, a library boundary where you can't guarantee purity — not a knob to silence the rule warning on code you should actually fix.

A worked example: what changes in practice

Before:

function UserRow({ user }) {
  // Hand-stabilizing an object literal so the memoized child skips renders.
  const style = useMemo(
    () => ({ opacity: user.active ? 1 : 0.5 }),
    [user.active]
  );
 
  // Hand-stabilizing the handler so the memoized child skips renders.
  const onBookmark = useCallback(
    () => bookmark(user.id),
    [user.id]
  );
 
  return <UserCard user={user} style={style} onBookmark={onBookmark} />;
}
 
const UserCard = memo(function UserCard({ user, style, onBookmark }) {
  return (
    <div style={style}>
      <button onClick={onBookmark}>Bookmark {user.name}</button>
    </div>
  );
});

After, with the compiler on:

function UserRow({ user }) {
  const style = { opacity: user.active ? 1 : 0.5 };
  const onBookmark = () => bookmark(user.id);
  return <UserCard user={user} style={style} onBookmark={onBookmark} />;
}
 
function UserCard({ user, style, onBookmark }) {
  return (
    <div style={style}>
      <button onClick={onBookmark}>Bookmark {user.name}</button>
    </div>
  );
}

Both files produce the same render behavior. The compiler emits caching helpers around style, onBookmark, and UserCard itself. The second version has no memoization noise and reads as the component it actually is.

The common regressions, and what they really are

When a team turns the compiler on and something visibly breaks, the cause is almost always one of three shapes:

  • "The screen is stale." A component read a value from a ref during render, and relied on the forced re-render to pick up changes. The compiler sees a pure read, caches the result, and the screen stops updating. Fix: move the read into an effect, or lift the value to state.
  • "A list item is showing the wrong user." A component received a prop object and mutated it. The compiler cached the previous render's derived value; the mutated field doesn't show up until something else invalidates the cache. Fix: stop mutating the prop; pass a new object.
  • "The button no longer does anything." A callback read a value that was declared with let and reassigned during render. The compiler froze the first value. Fix: lift the mutable state into useState or a ref; stop reassigning during render.

In all three, the compiler surfaced a bug that was already there — the old code "worked" because the re-render rate hid it.

What to remember

  • The React Compiler is a build-time transform that inserts the memoization you used to write by hand — stable references, component output caching — on code that follows the Rules of React.
  • Most useMemo / useCallback / React.memo calls that existed for performance become unnecessary. Keep the ones that exist for correctness (effect dependencies, deliberate caches).
  • The compiler bails on components that break the Rules of React, and the ESLint plugin tells you which rule and where. Fix the violation; don't silence the rule.
  • Adopt incrementally: ESLint rule first, then the Babel plugin, then delete redundant manual memoization. Smoke-test the critical flows after the plugin goes on.
  • "use no memo" is a per-component escape hatch for genuine exceptions, not a workaround for rule violations.
  • React DevTools' "Memo ✨" badge is your visibility — it marks every component the compiler successfully optimized.

Check yourself

5 questions · pass 4/5 to unlock Keys and List Reconciliation Pitfalls

up to 50
  1. 1.What does the React Compiler actually do at build time?

  2. 2.After the compiler is on and working, which of these should you keep writing by hand?

  3. 3.A component mutates an object inside its render — props.user.lastSeen = Date.now(). The compiler flags it with react-compiler/mutate-during-render. Why?

  4. 4.What's the "escape hatch" if the compiler is optimizing a component you don't want it to touch (an experimental hook with intentional side effects, say)?

  5. 5.You enable the compiler on an existing app and a handful of components regress visually. What's the realistic first debugging step?

5 left to answer

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.