I put off turning on the React Compiler for almost a year. Not because I doubted it would work, but because every time I read the docs I’d think “okay, but my app already memoizes the right things, what would actually change?” and close the tab.
Then I had a Sunday with nothing to do, so I flipped it on in a real Next.js app I’d been babysitting for about eighteen months. The compiler is stable in React 19, the eslint plugin had calmed down, and the worst case was reverting one PR.
What I found is that I’d been writing a lot of memoization that the compiler now does for me, and a few patterns I genuinely hadn’t realized were broken. Here’s a tour of what I deleted, what I kept, and the rough edges that aren’t in the announcement posts.
What the compiler actually does
The marketing line is “automatic memoization.” That’s accurate but underselling. What it actually does is read your component, work out which values derive from which inputs, and emit a version that only recomputes things when the inputs change. Effectively, it inserts the useMemo and useCallback calls a careful person would insert, and inserts them in places a careful person wouldn’t bother.
If you’ve read the React Compiler reference, you’ve seen the example with a <TodoList> and a sort callback that the compiler memoizes for you. In a real app the wins are subtler. You stop seeing prop changes that don’t matter trigger child re-renders, and the diff between commits gets smaller. I noticed it most in a few list components where I’d given up on memoization because every parent change blew through it anyway.
It runs at build time. There’s no runtime cost beyond the slightly larger output, and the output is plain React: hooks, components, no special runtime. That’s the part I trusted most before turning it on.
The useMemo and useCallback I deleted
I went through the codebase the day after enabling the compiler and pulled hand-rolled memoization that no longer earned its keep. Here’s a representative chunk before:
function ProjectsTable({ projects, query, sortKey }) {
const filtered = useMemo(
() => projects.filter(p => p.name.toLowerCase().includes(query.toLowerCase())),
[projects, query]
);
const sorted = useMemo(
() => [...filtered].sort((a, b) => a[sortKey].localeCompare(b[sortKey])),
[filtered, sortKey]
);
const handleRowClick = useCallback(
(id) => router.push(`/projects/${id}`),
[router]
);
return <Table rows={sorted} onRowClick={handleRowClick} />;
}
After:
function ProjectsTable({ projects, query, sortKey }) {
const filtered = projects.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
const sorted = [...filtered].sort((a, b) =>
a[sortKey].localeCompare(b[sortKey])
);
const handleRowClick = (id) => router.push(`/projects/${id}`);
return <Table rows={sorted} onRowClick={handleRowClick} />;
}
That’s the whole point. The compiler sees that filtered only depends on projects and query, and that sorted only depends on filtered and sortKey, and emits cached versions. The function identity for handleRowClick is now stable across renders unless router actually changes.
Across the project I removed about forty useMemo and useCallback calls. Maybe twelve of them were doing real work; the rest were premature, copy-paste guards I’d added because some prop was getting passed to a memoized child and I didn’t want to think about it. The diff was satisfying in a small petty way.
The places it didn’t help
Two things bit me, and neither is in the headline announcement.
The first is referential equality across hooks I don’t own. If a third-party hook returns a fresh object every render, no compiler in the world can save you. I had a date-range hook that returned { from, to } as a new literal each call. The compiler dutifully memoized everything downstream of it, but the inputs themselves changed every render, so nothing was actually cached. The fix was to wrap the hook (or, in my case, replace it with a tiny one that returned a stable reference). Worth checking your dependencies before assuming the compiler magically fixed things.
// The third-party hook (out of my control)
function useDateRange() {
// returns a new object literal every render
return { from: startOfWeek(new Date()), to: new Date() };
}
// My wrapper that gives me a stable reference
function useStableDateRange() {
const { from, to } = useDateRange();
return useMemo(
() => ({ from, to }),
[from.getTime(), to.getTime()]
);
}
The second is useEffect dependencies. The compiler doesn’t rewrite your effects’ dependency arrays. If you had a nasty effect that re-ran because onChange was a new function each render, that’s still a problem, except now onChange is stable, so the bug presents differently. I had one effect that used to fire constantly, then started firing once per real change after I enabled the compiler, and that exposed a race I hadn’t noticed before. Net positive, but worth knowing.
I poked at a few of these interactions earlier this year in the React 19 features I actually use six months in, if you want a related read.
The eslint plugin is the real shipping criterion
Here’s what nobody told me clearly: the compiler will silently bail on components it can’t safely transform. That’s correct behavior, since an unmemoized component beats a wrong one, but it means you can ship something you think is compiled and have a quarter of your tree opt out without knowing.
The fix is eslint-plugin-react-compiler. Turn it on with error, not warn. It flags every place the compiler had to bail, which is mostly violations of the Rules of React: mutating a value during render, calling hooks conditionally, that kind of thing.
// .eslintrc.json
{
"plugins": ["react-compiler"],
"rules": {
"react-compiler/react-compiler": "error"
}
}
In my codebase the lint pass surfaced about thirty bail-outs. Most were genuine bugs in waiting: a util that mutated its argument, a couple of components reading from a singleton mid-render. Two were false positives where the compiler is conservative about destructuring patterns. I rewrote those rather than disable the rule per file, because once you start sprinkling // eslint-disable you stop trusting the signal.
Turning it on in Next.js and Vite
For Next.js (App Router, version 15.1+), it’s a config flag:
// next.config.js
module.exports = {
experimental: {
reactCompiler: true,
},
};
For Vite, install the babel plugin and add it to the React plugin’s babel options:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', {}]],
},
}),
],
});
A few things I learned the hard way. Don’t bother with the “compile only annotated components” mode in a real codebase, because you’ll forget. Compile everything and let the bail-outs surface in lint. Build times went up about 12% on a 400-component app, which is not nothing but not enough to argue about. The bundle size went up by maybe 4 KB after gzip in my case, which is the inserted memo cache. That’s a small price to pay for not chasing stale closures.
Should you turn it on
If you’re on React 19 and you’ve been writing useMemo and useCallback defensively, yes. The compiler does that work better than you do, and you’ll find a few real bugs along the way.
If you’re still on React 18, the compiler does work there with a runtime polyfill, but the gains are smaller and the upgrade path to 19 is the bigger fish. Spend the time on that first.
If you have a custom renderer, a heavy reliance on third-party hooks, or a codebase that’s been violating the Rules of React for a long time, expect a week of cleanup before things settle. That’s not a reason to skip it. That’s a reason to do it now, while the tooling is fresh and the bail-out reports are short.
What to do this week
One concrete thing you can run this week: install eslint-plugin-react-compiler, set it to warn, and look at the report. You don’t have to flip the compiler on yet. The lint output alone is worth the half-hour, because every bail-out is either a real bug or a place the compiler is being conservative about something you can simplify.
I write a lot about this kind of incremental adoption in the rest of my work. The pattern is always the same: ship the diagnostic before you ship the fix. The compiler is a fix you can ship two weeks after you’ve seen what your codebase actually looks like through it.