Skip to content

React Compiler in Production: The useMemo Boilerplate I Actually Deleted

React Compiler in Production: The useMemo Boilerplate I Actually Deleted

Confession: I put off turning on the React Compiler for almost a year. It was in beta, I was busy shipping, and I had a nagging suspicion that “the compiler memoizes for you” was going to turn into “the compiler memoizes for you, except in the six places you actually cared about”. I was wrong about most of that, but not all of it.

So here’s what six months of running the React Compiler on a production Next.js app actually looks like. What I deleted, what I kept, where it bailed out on me, and the one bug that made me briefly regret the whole thing.

What the compiler actually does

The short version: it reads your components and hooks at build time, figures out what depends on what, and inserts memoization automatically. So the useMemo, useCallback, and React.memo you were writing by hand? A lot of it becomes unnecessary. The compiler puts equivalent caching in the compiled output based on real dependency analysis instead of the dependency array you tried to keep in sync.

The official docs are worth reading in full, especially the rules the compiler assumes, because “assumes” is doing a lot of work in that sentence. If your code follows the Rules of React (pure components during render, no mutating props, no reading refs during render), the compiler optimizes it. If you break the rules, it bails out on that component and logs a diagnostic. It does not silently produce wrong code, which is the guarantee I actually cared about.

The useMemo pile I finally deleted

Here’s a component from our dashboard, pre-compiler. It was doing table row filtering and had grown a small forest of memoization over two years.

function InvoiceTable({ invoices, filters, onSelect }) {
  const filtered = useMemo(() => {
    return invoices.filter(i => matchesFilters(i, filters));
  }, [invoices, filters]);

  const totals = useMemo(() => {
    return computeTotals(filtered);
  }, [filtered]);

  const handleRowClick = useCallback((id) => {
    onSelect(id);
  }, [onSelect]);

  const Row = useMemo(() => memo(InvoiceRow), []);

  return (
    <table>
      <TotalsBar totals={totals} />
      {filtered.map(i => (
        <Row key={i.id} invoice={i} onClick={handleRowClick} />
      ))}
    </table>
  );
}

Here’s the same component with the compiler on.

function InvoiceTable({ invoices, filters, onSelect }) {
  const filtered = invoices.filter(i => matchesFilters(i, filters));
  const totals = computeTotals(filtered);

  return (
    <table>
      <TotalsBar totals={totals} />
      {filtered.map(i => (
        <InvoiceRow key={i.id} invoice={i} onClick={onSelect} />
      ))}
    </table>
  );
}

I counted. Across the whole app I deleted 214 useMemo calls, 187 useCallback calls, and 41 React.memo wrappers. Performance was flat or slightly better under the React DevTools profiler in every route I checked. The wins were not in speed. The win was that the code stopped lying to me about which values were stable, because the compiler actually knows.

I want to be careful here. I did not delete every memo. About a dozen useMemo calls were doing expensive work that I wanted cached even when parents rerender for unrelated reasons, and the compiler agreed with keeping them once I re-added the same logic in a normal function call. It memoizes them anyway. Same result, less code.

Turning it on without setting your CI on fire

In Next.js 15+, it’s one flag:

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

In a plain Vite or Babel setup, install babel-plugin-react-compiler and add it as the first plugin. Order matters. Put it before anything else, including the React refresh plugin, or you’ll get confusing errors during development that look like Fast Refresh bugs.

The part I wish I had done first: install eslint-plugin-react-compiler and let it run against the whole codebase. It flags every component the compiler will bail out on, plus the reason. In our codebase this surfaced about 40 files. Most were harmless (a top-level if (typeof window !== "undefined") block, a component that read a ref during render), but a few were genuine bugs that had been silently working because we were also manually memoizing incorrectly. Fixing those first meant when I flipped the flag, the compiler could actually do its job.

One pattern that comes up a lot: components that read from mutable module-level state. The compiler will refuse. The fix is usually to move that state into a proper store (Zustand, Redux, or React context via the use hook I wrote about here). This is a good change to make anyway, but it’s not zero work.

The bug that made me briefly regret it

About three weeks in, we shipped a subtle bug: a form field would occasionally not update its displayed value after a server-side revalidation. The state was correct. The DOM was wrong. I spent an evening blaming the compiler before I realized I had a component that mutated a prop object in a useEffect. The pre-compiler version happened to work because the parent was also creating a fresh object every render (thanks to the incorrect useMemo I had written years earlier). With the compiler on, the parent correctly reused the memoized object, my child correctly mutated it, and React had no way to know anything had changed.

The compiler didn’t cause the bug. It exposed it. But if you have a codebase with a lot of hidden reliance on “parents render everything fresh, so mutations don’t matter”, you are going to find those spots the hard way. Turn on strict mode if you haven’t. Turn on the ESLint plugin. Read the diagnostic output.

What I still write by hand

A short list, because “the compiler handles it” is not the whole story:

  1. useEffect cleanup. The compiler does not touch effect logic. If your effect wasn’t cleaning up subscriptions before, it still isn’t.
  2. Server actions and data fetching. The compiler is about render caching. Data caching is Next.js’s cache(), TanStack Query, SWR, or your own layer. Different problem, different tool.
  3. useOptimistic and other cases where you want explicit control over intermediate UI states. I covered where I actually reach for useOptimistic in another post; the compiler isn’t a substitute.
  4. The "use no memo" escape hatch, for cases where you deliberately want a component to rerender aggressively. Rare, but I have used it twice in a debug panel.

Also: I still write React.memo around the two or three components that render on nearly every keystroke and take a big object as a prop. The compiler covers this in most cases, but I like the explicit signal in the source.

Bundle size and build time, in case you were wondering

My production bundle grew by about 3.2 KB gzipped after enabling the compiler. That’s the runtime helpers for its memoization primitives. It is not zero. On the other hand, I deleted enough manual memoization code that my source shrank by 4.8 KB. So the emitted JS is basically flat.

Build time went up by roughly 12% on our monorepo. Not a shocking hit, but if your CI is already slow, factor it in. Turbopack and SWC teams are actively working on making this cheaper, so this number will move.

Should you turn it on?

Yes, but not blindly. Here’s the actual thing to do this week:

Install eslint-plugin-react-compiler in your project. Run it. Read the report. Fix the top ten violations. That alone is worth doing regardless of whether you enable the compiler, because those are real Rules of React violations and probably real latent bugs.

Then enable the compiler on one route or one feature flag. Watch it for a week. If nothing breaks, expand. If something breaks, the diagnostic messages are unusually good.

If you’re the kind of person who wants to see the actual pattern shifts in a real codebase, that’s the kind of work I cover on my portfolio alongside a few other Next.js migrations. But you don’t need me for this one. The compiler is boring, in the good sense: it does what it says and mostly gets out of the way. That’s rare, and worth the afternoon it takes to try.