What happened when we turned on React Compiler in a real React 19 app, and the two places where it couldn't save us.
Our dashboard had 214 useMemo\ and useCallback\ calls. I know because I counted them with a grep one Friday afternoon, and then I stared at the number for a while.
Nobody sat down and decided to write 214 of them. They accumulated. One slow table here, one flickering chart there, a code review comment that said "wrap this, it's getting new props every render." Two years later, half our components looked like they were wearing a bulletproof vest to the grocery store.
Then we enabled React Compiler. This is the story of what we deleted, what actually got faster, and the two places where the compiler shrugged and said "that's your problem."
The setup
The app is a React 19 dashboard with a big virtualized data table, a handful of charts, and a timeline scrubber you can drag around. We're a product team, not a performance team, so the memoization was mostly defensive.
Turning the compiler on was boring, which is the best thing I can say about it. On the Next.js side it's a config flag. If you're on Vite, it's one Babel plugin:
\`ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', {}]],
},
}),
],
});
`\
We didn't flip it on for everything at once. We used the compiler's annotation\ mode first, so only components with a "use memo"\ directive were compiled, and opted in feature by feature. After two weeks of that, we switched to the default mode and used the opt-out directive ("use no memo"\) for the handful of components we weren't ready to trust yet.
The most useful step happened before any of that. We ran the healthcheck (npx react-compiler-healthcheck@latest\) and turned on the compiler-aware lint rules in eslint-plugin-react-hooks\. The healthcheck told us how many components the compiler could handle. The linter told us why it couldn't handle the rest. That list was embarrassing, and I'll come back to it.
What we deleted
Here's a typical "before." This is a filtered, sorted list component, roughly as we had it:
\`tsx
function OrderList({ orders, filter, onSelect }: Props) {
const visible = useMemo(
() => orders.filter((o) => o.status === filter),
[orders, filter]
);
const sorted = useMemo(
() => [...visible].sort((a, b) => b.createdAt - a.createdAt),
[visible]
);
const handleSelect = useCallback(
(id: string) => onSelect(id),
[onSelect]
);\n\n return (\n
- \n {sorted.map((o) => (\n \n ))}\n
\\n\nAnd the "after":\n\n\tsx\nfunction OrderList({ orders, filter, onSelect }: Props) {\n const sorted = orders\n .filter((o) => o.status === filter)\n .sort((a, b) => b.createdAt - a.createdAt);\n\n return (\n <ul>\n {sorted.map((o) => (\n <OrderRow key={o.id} order={o} onSelect={onSelect} />\n ))}\n </ul>\n );\n}\n\nexport default OrderList;\n\\\n\nIt reads like the code you'd write on day one. That's the real win, and it matters more than any benchmark. There's no dependency array to get wrong, no stale closure hiding in a useCallback\, and no "why is this wrapped?" archaeology.\n\nWe deleted about 60% of the manual memoization over three PRs. I kept each PR small on purpose: one feature area, one measurement before and after. If something regressed, I wanted to know exactly which deletion did it.\n\nThe compiler also memoizes things you simply can't memoize by hand. Hooks can't be called after an early return, so this was always awkward:\n\n\tsx\nfunction Details({ id }: { id: string }) {\n const item = useItem(id);\n if (!item) return <Skeleton />;\n\n const summary = buildSummary(item);\n return <Summary data={summary} />;\n}\n\\\n\n## How we measured (and what we found)\n\nI'd love to tell you we saw a 4x speedup. We didn't, and I'd be suspicious of anyone who says they did on an app that was already memoized.\n\nWe measured three things:\n\n- Commit counts and render durations in the React DevTools Profiler, replaying the same scripted interaction before and after each PR.\n- INP from our real-user monitoring, over a week before and a week after.\n- Bundle size, because the compiler adds some code.\n\nThe results, roughly: for the parts of the app that were already well-memoized, nothing changed. Commit counts stayed the same, durations stayed within noise, and that's the correct outcome. Bundle size went up slightly, a couple of percent on the main chunk.\n\nThe wins came from places we'd never memoized. A settings panel and a few screens had been quietly re-rendering whole subtrees on every keystroke. On mid-range Android devices, INP on those screens improved noticeably. Auto-memoization is great at the code nobody had time to optimize.\n\nSo the honest summary is: no speedup where we'd been careful, a real one where we hadn't, and a lot less code overall.\n\n## What broke in staging\n\nTwo things broke, and neither was the compiler's fault in the way I first assumed.\n\n*First: the silent bail-outs.* The compiler doesn't crash on code that breaks the Rules of React. It skips the component and moves on. We had a component doing this:\n\n\tsx\nfunction Leaderboard({ scores }: { scores: Score[] }) {\n const ranked = scores.sort((a, b) => b.value - a.value);\n return <RankedList items={ranked} />;\n}\n\\\n\n.sort()\ mutates its input, and here that input is a prop. The compiler can't safely optimize that, so it skipped the component. The fix was one line ([...scores].sort(...)\).\n\n*Second: a parent outside the compiler's reach.* A legacy chart wrapper passes a fresh options\ object to a compiled child on every render. The compiler can't fix a prop that changes identity before it reaches compiled code. We fixed it at the boundary with a manual useMemo\. Anywhere the compiler's guarantees stop, yours start again.\n\n## The one that was never a memoization problem\n\nOur timeline scrubber updates a playhead on every pointermove\. We deleted most memoization, and the drag still stuttered. The compiler makes rendering cheaper; it doesn't make rendering free. We were pushing 60+ state updates per second.\n\nThe fix was to stop using React state for the drag entirely: update a CSS variable from requestAnimationFrame\ and tell React only when the drag ends. High-frequency input was never a memoization problem.\n\n## What I'd do differently\n\n- Run the healthcheck and lint rules before touching anything. Treat the lint output as the migration backlog.\n- Delete memoization in small PRs with measurements. Bulk deletes make regressions impossible to bisect.\n- Keep a list of the boundaries. Wherever compiled code meets uncompiled code, you're back in manual territory.\n\n## My recommendation\n\nIf you're enabling the compiler on an existing codebase, here's the order I'd follow:\n\n1. Don't delete anything yet. Enable the compiler, run the healthcheck, and fix lint violations first. It coexists with existing useMemo\ calls, so there's no rush.\n2. Roll out gradually. Start in annotation mode, or opt out risky components with "use no memo"\.\n3. Profile before and after each deletion. If numbers don't move, delete. If they regress, you've found a boundary or rule violation.\n4. Keep manual memoization at the edges. Uncompiled parents and third-party components that depend on reference equality still need it.\n5. Take high-frequency input out of React state. Pointer moves, scroll handlers, and drags belong in refs, CSS variables, or requestAnimationFrame\.\n\nWe went from 214 manual memos to about 80. Every survivor has a reason, and now that the rest are gone, those reasons are easy to see. That alone was worth the migration.
Top comments (0)