Some React code exists only to stop React from doing work it would otherwise do: a useMemo around a derived array, a useCallback around a handler passed to a memoised child, a React.memo around a component that re-rendered too often in a profiler session three years ago. None of it is business logic. All of it needs maintaining, and every dependency array is a small bug waiting for the next refactor.
The React Compiler is the project that promised to make that code unnecessary.1 It does at build time what the hooks did by hand: it looks at each component, works out which values can change between renders, and caches the rest. The claim is that a component written in plain React with no memoization comes out of the compiler as fast as the hand tuned version, or faster.
I had a production app with 212 of those hooks in it, so I turned the compiler on and deleted them all to see what the claim was worth.
The app and the method
It's a dashboard with about 180 components, on React 19 and Next.js 16, with TanStack Query for data: heavy tables, a few charts, forms with a lot of controlled inputs. It had been profiled and tuned for years, which is how it ended up with 212 memoization hooks and 31 React.memo wrappers. It wasn't slow. The question was whether it would stay that way without them.
The method was blunt. Turn on the compiler:
// next.config.ts
export default {
reactCompiler: true,
};
Run the app and check nothing broke. Then strip every useMemo, useCallback and React.memo with a codemod, run it again, and measure. I used the React DevTools profiler on the five interactions that had always been the slow ones: opening the main table with 2,000 rows, sorting it, typing in the filter box, opening the row detail drawer, and switching date ranges on the charts.2
The result
| Interaction | With hooks | Compiler, no hooks |
|---|---|---|
| Open 2,000 row table | 142 ms | 138 ms |
| Sort table | 61 ms | 59 ms |
| Type one character in filter | 18 ms | 17 ms |
| Open detail drawer | 34 ms | 36 ms |
| Switch chart range | 88 ms | 84 ms |
All of it is within noise. I stared at those numbers for a while, because I'd expected at least one to get worse. Two thousand table rows with a handler on every cell was the case I was sure would fall over without useCallback. It didn't, because the compiler memoised the handler the same way I had, and got it right, which is more than I can say for two of the manual versions.
The number that did change was the size of the codebase. Removing the hooks deleted about 1,400 lines, including the dependency arrays and the comments explaining why a particular dependency was left out on purpose.
What the compiler does, briefly
It isn't magic, and it helps to know what it actually does so the exceptions make sense.
The compiler rewrites each component and hook so that every expression is cached in a slot and only recomputed when its inputs change. It works out the inputs by analysing the code, which is what your dependency array did by hand, except the compiler never forgets a dependency or adds one that isn't needed. It does this for values, for JSX and for functions, so a handler defined inline stays stable across renders as long as whatever it closes over is stable.
For that analysis to hold up, the component has to follow the rules of React: no mutating props or state, no reading refs during render, pure render functions. When the compiler sees a component break a rule, it skips that component completely and leaves it as written. It doesn't try anything clever with code it can't prove safe. By default it skips silently, which brings me to the first exception.
Exception one: the components it refused
The compiler skipped nine of the 180 components. An ESLint plugin, eslint-plugin-react-compiler, reports the reason for each skip, and every reason was legitimate.
Four components mutated an object from props to add a computed field before rendering it. That breaks the rules.3 The fix was to derive a new object.
Three read ref.current during render to decide what to draw. Two were legacy code from before useSyncExternalStore existed, and moved to it. One was a real measure-then-render pattern, and it moved to useLayoutEffect with state.
Two called a hook conditionally in a way that was technically fine, because the condition never changed, but the compiler couldn't prove that. Those got restructured.
After the fixes the compiler handled all 180. The skips weren't the compiler failing. They were nine places where the code had been breaking the rules for years, with the manual hooks covering for it.
Exception two: the memo that was doing something else
Four of the 212 hooks turned out to matter in a way that had nothing to do with rendering speed.
Two useMemo calls created objects that were used as keys in a WeakMap cache somewhere else. The memo kept the object identity stable so the cache would hit. The compiler happens to keep it stable too, but that's an implementation detail, and relying on it for correctness is wrong for the same reason relying on useMemo for correctness always was.4 Those two became explicit, with the key stored in a ref.
One useCallback was handed to a third party library that registered it as an event listener on mount and never registered it again. Without a stable reference, the listener would have been the first render's closure forever. The compiler keeps the reference stable as well, so nothing broke, but once again the code depended on an optimisation, so it got an explicit ref.
One React.memo sat on a component that got a new inline object as a prop on every parent render, with a custom comparison function to deep compare it. That's a real case. The compiler memoises on identity, not on structure, so if the parent makes a new object every time, the child re-renders every time. The fix went in the parent, which now creates the object once, and after that the memo with its custom comparator was deleted.
So four of 212 were doing something the compiler doesn't promise to do, and every time the right change was to stop depending on memoization for correctness. The other 208 did exactly what the compiler does, by hand and less reliably.
Exception three: the expensive computation
There's one thing the compiler doesn't do, and it's the case useMemo was invented for in the first place. If a component computes something expensive from its props, the compiler caches the result and skips the work when the props haven't changed, just like useMemo. But if the computation is expensive and the props really do change on every render, neither one helps, and the fix is to do the work somewhere else.
I had one of those: a chart component that ran a 40 ms aggregation over the raw series on every render, memoised on the series, where the series was a new array from the query on every refetch, every 30 seconds. The memo had hidden the fact that the aggregation belonged in the query's select function, once per fetch, and not in the component. I moved it, the component got simpler, and that was the only change in the whole exercise that made something measurably faster.
What I would tell someone starting today
Turn the compiler on before you write a single hook. In a new project there's no reason to write useMemo or useCallback at all. If you catch yourself reaching for one, ask what you're actually trying to keep stable, and why.
In an existing project, turn it on, run the ESLint plugin, and fix the components it skips. Those fixes are worth doing anyway. Then delete the hooks in a separate commit so the diff can be reviewed, and profile the interactions you care about before and after. Expect flat numbers.
Watch for the exceptions I found: identity used as a cache key, a reference captured by something that never reads it again, structural comparison in a memo, and expensive work that belongs in the data layer. In each of those the hook was doing more than hinting at performance, and each is better written out explicitly.
The React team said the compiler would let us stop thinking about memoization. In my app it did that, and it also showed me the eight or nine places where I'd been thinking about it wrong, which I got more out of.
Originally published at zeybek.dev.
-
It went stable in late 2025, and it's the default in new Next.js 16 projects. ↩
-
Each one was measured ten times before and after, on the same machine with the same data. ↩
-
It had never caused a visible bug because the object came from a query result and nothing else read it afterwards. ↩
-
The React docs have said for years that
useMemois a performance hint that may be dropped. ↩
Top comments (1)
Ripping out all those manual hooks and letting the compiler handle the caching is exactly where React needed to go. Manually tracking dependency arrays was always a brittle way to manage renders, mostly just creating traps for stale closures during future refactors. The fact that a heavy dashboard with TanStack Query tables stays snappy without a single hand-tuned memoization proves the build step is finally doing the heavy lifting we used to do by hand.