Skip to content

React Compiler: What I Deleted After Turning It On

React Compiler: What I Deleted After Turning It On

I turned on the React Compiler in a real app back in March, fully expecting to lose a weekend to cleanup. It took about forty minutes. I deleted a depressing number of useMemo calls, and then sat there feeling a little insulted by how much hand-wired memoization I’d been hauling around for years.

Here’s the confession. I was one of those people who put useCallback on every function I passed to a child component, because a blog post in 2021 told me to. I never measured whether it helped. I just did it, the way you wear a lucky shirt to an interview.

The compiler made most of that pointless. Not all of it. There’s a real gap between “memoization is handled for you now” and “you can delete every optimization in the codebase,” and I fell into that gap twice before the rules clicked. So here’s what actually changed for me, what I deleted, and the two places where the compiler quietly does nothing.

What the compiler actually does

The short version: the React Compiler reads your components and hooks at build time, works out what depends on what, and inserts memoization for you. Values that don’t need to change between renders keep the same reference. Components stop re-rendering when their inputs are the same as last time. You don’t write any of it by hand.

Before, that bookkeeping was your job. You decided what to wrap in useMemo, which callbacks needed useCallback, and where a React.memo boundary earned its keep. Get it wrong in one direction and you re-render too much. Get it wrong in the other and you cache a stale value and chase a bug for an afternoon. The compiler moves that decision into the build step, where it can see the whole component at once instead of relying on you to remember.

It shipped as part of the React 19 release, and the thing I underestimated is that it’s conservative. If it can’t prove a component follows the Rules of React, it skips that component and leaves your code exactly as written. It would rather do nothing than miscompile.

The useMemo and useCallback I deleted

This is the part that felt good. Here’s a list component the way I’d have written it for React 18:

function ProductList({ products, query }) {
  const filtered = useMemo(
    () => products.filter((p) => p.name.includes(query)),
    [products, query]
  );

  const handleSelect = useCallback(
    (id) => analytics.track("select", id),
    []
  );

  return <List items={filtered} onSelect={handleSelect} />;
}

Two hooks, two dependency arrays, two chances to get the dependencies wrong. With the compiler on, the same component is just this:

function ProductList({ products, query }) {
  const filtered = products.filter((p) => p.name.includes(query));
  const handleSelect = (id) => analytics.track("select", id);

  return <List items={filtered} onSelect={handleSelect} />;
}

The compiler memoizes filtered so the filter only runs again when products or query change. It keeps handleSelect stable so <List> doesn’t re-render just because the parent did. I wrote the obvious code and got the optimized behavior for free, which is roughly the opposite of how the last decade of React felt.

I did the same cleanup I wrote about when I stopped writing forwardRef by hand: rip out the ceremony, keep the component, confirm nothing regressed in the Profiler. The dependency arrays were the part I was happiest to see go. Most of the memoization bugs I’ve shipped came from a dependency array that drifted out of sync with the function inside it.

The quieter win is what it did to code review. We used to argue about whether a given useMemo was pulling its weight, and nobody could answer without profiling, so the comment thread just died and the hook stayed. With the compiler doing the bookkeeping, those threads stopped happening. A pull request now shows the actual logic instead of a layer of caching wrapped around it, and a reviewer can read what the component does rather than reverse-engineering why three hooks are there. I didn’t expect a build tool to make diffs easier to read, but that’s been the change I notice most often, more than any frame-rate number.

Turning it on without breaking everything

I didn’t flip a switch and pray. The compiler is a Babel plugin, and the safest path is to add it behind the linter first.

// babel.config.js
module.exports = {
  plugins: [
    ["babel-plugin-react-compiler", { target: "19" }],
  ],
};

Before that, I ran the compiler’s ESLint rule across the codebase to see how many components broke the Rules of React. That number is your real migration cost. A component that mutates props during render, or reads a ref in the wrong place, is a component the compiler will refuse to touch. The installation guide walks through the incremental path, and I’d push back on anyone who tells you to skip it. Knowing which files the compiler is ignoring is more useful than knowing it’s “on.”

When you find a component that misbehaves under compilation and you can’t fix it that minute, you can opt it out with a directive instead of reverting the whole rollout:

function LegacyChart(props) {
  "use no memo";
  // ...the gnarly one you'll refactor later
}

I had exactly one of those. It was a chart wrapper doing something cursed with a mutable ref, and tagging it bought me time to fix it properly the following week.

Where it still doesn’t save you

Now the part nobody puts in the launch posts. I watched the compiler do nothing useful in two situations, and both times the problem was mine, not the compiler’s.

The first is genuinely expensive work whose inputs change every render. Memoization caches a result keyed on its inputs. If a parent hands you a freshly built object on every render, the key is never the same twice, so there’s nothing to reuse:

// Parent rebuilds `options` every render, so the key is always new.
<Report options={{ range: "30d", tz: userTz }} data={rows} />

The compiler can’t memoize across renders when the input is never stable. The fix lives upstream: build that object once, or lift it out, or pass primitives. No build step rescues a render that throws away its own inputs.

The second is effects firing too often because of a dependency created outside React’s view. The compiler reasons about component and hook scope. A value living in module scope or a non-React store isn’t something it tracks, so an effect that depends on it keeps re-running. That’s usually a data-flow problem, and these days I reach for a proper query layer instead, which I got into when I wrote about what I use instead of useEffect for fetching. The compiler optimizes rendering. It doesn’t reorganize your architecture, and it’s honest enough not to pretend otherwise.

So should you turn it on this week?

Yes, but in a branch, and with the Profiler open. Here’s the order I’d run it in. Add the ESLint rule and read the violations before you change a single line, because that list is the actual scope of the work. Turn the compiler on in a build and confirm the app still behaves. Profile one real interaction, the slow one you already know about, and compare before and after. Only then start deleting useMemo and useCallback, and delete them in small batches so a regression is easy to trace.

What I’d avoid is the bulk find-and-replace that strips every memoization hook in one commit. The compiler is good, but a few of those hooks were load-bearing for reasons you’ve forgotten, and you want to find that out one file at a time. I keep a running list of these “things I finally deleted” write-ups in my work, and this one was the most satisfying entry in a while, mostly because the cleanup was real and the risk was low.

If you’ve got an afternoon, start with the ESLint rule. It tells you more about your codebase than the compiler itself does.