React Compiler: where to delete useMemo, and where it quietly bails
I turned the compiler on in production and went to delete my useMemo. Where that worked, where it silently skipped the component, and why selectors and caches stay mine.
Last spring I turned React Compiler on in the Mail.ru Cloud file-manager monorepo. It was still pre-1.0 then: a Babel plugin, rolled out behind a gating flag so we could enable it package by package instead of all at once. The first review question was the obvious one. If the compiler memoizes everything, do we delete the manual useMemo and useCallback? I opened the virtualized file list, the one we’d hand-tuned to 60fps a while back, and started pulling memo off FileRow and useCallback off onSelect. I nearly sent that to review. Then I looked at what the compiler had actually done with the component, and the answer was nothing. It had skipped it.
That’s the short version of the title. You can delete useMemo in plenty of places, but the spots where memoization mattered most are exactly where the compiler tends to back off, and it does it quietly. A year on, the compiler is stable and running across almost all of our packages. The question from that review hasn’t gone anywhere. Below is how it decides, what I ended up deleting, what I kept, and the rules all of it rests on.
What it actually does
React Compiler runs at build time. It builds a data-flow graph inside a component, works out which value depends on what, and inserts memoization wherever it can prove an input didn’t change between renders. The output is roughly what you’d write by hand with React.memo, useMemo, and useCallback, placed by the compiler and tucked into a hidden cache: it allocates an array of slots per component (this used to be the useMemoCache hook; in 1.0 it’s c() from react/compiler-runtime) and checks inputs against the slots on each render, reusing the previous result when an input is unchanged.
It derives the dependencies from what it sees in the function body. Not from an array you wrote out, but from the code itself: which props an expression reads, which variables flow into it. For a pure component that beats a hand-written dependency list, because the compiler doesn’t forget a dependency or add a spurious one. The whole mechanism rests on one condition: the component body has to be honest about what it depends on. The moment something enters the render that the compiler can’t see or can’t treat as stable, it doesn’t guess. It declines to optimize the component as a whole.
Before and after, same list
In an earlier post on virtualization I went through what makes a twelve-thousand-file folder scroll smoothly. Windowing does the structural part. The render work around it gets you the rest of the way to 60fps: memo on the row, a stable onSelect, a memoized selector. Those three are the cleanest way to show where the compiler’s edge runs.
It takes memo on FileRow and useCallback on onSelect. That’s the work it exists for: the row stops re-rendering until its data changes, the handler keeps its reference across renders. I removed the manual wrappers, ran the same scenario (6x CPU throttle, a scripted scroll through the heaviest folder), and compared the scroll-frame commit against the hand-written version. The difference sat inside profiler noise: the commit landed at the same ~3ms I reported last time for the hand-written version, against ~40 with nothing memoized. On that stretch the compiler does replace the manual work.
// before: wrappers placed by hand
const FileRow = memo(function FileRow({ file, onSelect }: FileRowProps) {
return <div className="row" onClick={() => onSelect(file.id)}>{file.name}</div>;
});
// after: the compiler memoizes both the row and onSelect on its own
function FileRow({ file, onSelect }: FileRowProps) {
return <div className="row" onClick={() => onSelect(file.id)}>{file.name}</div>;
}
// const onSelect = useCallback(...) is gone too — the reference is stable without it
Then come the parts it doesn’t touch.
The selector lives outside React. The list reads a derived slice of the store: the current folder’s files, sorted and filtered. Memoizing that slice (RTK’s createSelector over a normalized store) lives in Redux, in store code. The compiler doesn’t see it and shouldn’t. If the selector hands back a new array on every call, the list gets a new prop on every render, and the component’s auto-memoization recomputes it honestly, because the input really did change. The rows inside can still hold, their props are stable, but the container rebuilds. The compiler doesn’t cancel out reselect. It runs on top of it and suffers the same way when it’s missing.
A mutable cache is the subtle one. That post had a trap: row offsets are computed from a cache of measured heights, the cache is mutable, a measurement gets written into a Map by file id without touching the ids array. A manual useMemo keyed on [ids] never saw the write and went stale, so the deps had to carry a version counter. The compiler derives dependencies from the same function body and arrives at the same [ids]. It treats the module-level Map as stable and leaves it out of the deps, so the memoization goes stale on the first late measurement, exactly the way the manual version did. That isn’t a bail-out, and the two are worth keeping apart: the compiler refuses to compile only when it sees something it catches statically, a ref read during render, a value mutated after it has gone into JSX. A plain read from an external Map isn’t that. Closing it is still on me, with the same tools as in that post: the version in the deps, or get the mutation out of render.
Virtualization sits entirely outside its field. Node count, overscan, scroll anchoring on a late measurement, a ResizeObserver per row, accessibility with aria-setsize over the full set — none of that is something the compiler touches. It works only inside the block I called the render work around the window. The structural half I still write by hand; the compiler doesn’t reach it.
What it leans on, and where it backs off
The whole thing rests on the Rules of React, and the compiler doesn’t so much add requirements as start punishing the old ones. Render has to be pure: same inputs, same output, no side effects on the way through. Props and state aren’t mutated. A value you’ve already handed to JSX or a hook doesn’t change in place afterward. Refs aren’t read or written during render. Hooks are called at the top level. Breaking these used to buy you a floating bug once a month. Now it also switches off optimization for that component.
At runtime a skipped component works like any React component, just without memoization, and there’s no error. There are actually three signals that it was skipped. The build output and the ESLint rule: the compiler’s lint rules are now part of eslint-plugin-react-hooks (you can drop the separate eslint-plugin-react-compiler), and the recommended preset flags both purity violations and the components the compiler declined to touch. And React DevTools, which tags optimized components with a “Memo ✨” badge, so a missing badge where you expected one is a per-component sign of a skip.
The badge comes with a catch. It only tells you the compiler processed the component, not that the memoization holds at runtime. A badged component still re-renders if a parent above it hands down unstable props, which is the Profiler’s job to catch. I treat the linter as mandatory either way: without it, “the compiler memoizes everything” stays a guess.
A skip looks roughly like this. Take a component that reads a ref during render, and three surfaces report it at once:
function FileGrid({ ids }: FileGridProps) {
const rowRef = useRef<HTMLDivElement>(null);
const rowH = rowRef.current?.offsetHeight ?? 48; // a ref read during render
// react-hooks/refs flags this line, the compiler skips FileGrid as a whole,
// and DevTools shows no "Memo ✨" badge on it, though its sibling rows have one.
return <FileGridView ref={rowRef} rowHeight={rowH} ids={ids} />;
}
A separate trap is the half-migrated component. The compiler compares your manual memoization against its inferred version, and when they disagree it declines to optimize the component. A leftover useMemo with the wrong dependencies is worse than dead weight: it can switch off compilation for the whole component. So either delete the manual memoization and hand it to the compiler, or keep it strictly correct; the in-between state, where a forgotten useMemo disagrees with the inferred version, switches compilation off right where you expected it.
So, delete useMemo or not
In most places, yes. But I stopped making the “delete” call by eye and tied it to two checks.
First: the component actually compiles. Deleting manual memo from a component the compiler skipped means bringing back the exact perf bug it was standing in for, on a hot path, where it hurts most. So before deleting I check the compiler output or the linter. Gut feel isn’t trustworthy here. The FileRow moment from the top was precisely this: I almost stripped memoization off a component that wasn’t compiling, and wouldn’t have caught it before the profiler.
Second: the useMemo held a stable reference that a contract depends on. Sometimes a value is memoized for its identity: a useEffect dependency relies on the reference, or a key does, or a value going into code the compiler doesn’t compile (a third-party library, a context, a boundary without the compiler). The compiler stabilizes the reference for rendering, but that’s a different promise. Those useMemo calls I check on their own and don’t sweep out with the perf ones.
There are escape hatches for this. The 'use no memo' directive at the top of a function turns compilation off for that component, which is the working way around a library that’s incompatible with auto-memoization, or to temporarily mute a suspect component while debugging. There’s the reverse, annotation mode, where only functions marked 'use memo' get compiled. And the compiler doesn’t tear out your manual memoization: if a useMemo is correct and matches what it would infer, you can leave it and nothing breaks.
What changed day to day
The reflex to write useCallback and React.memo just in case is gone. New code in compiled packages barely contains them, and it reads noticeably cleaner. The ESLint rule now does two jobs at once: it watches the Rules of React and tells me what won’t compile. Perf optimization effectively moved into the purity linter.
Review shifted. In virtualized and heavy code I used to scan for a missing memo, or for a fresh reference flowing into a memoized component. Now I scan for purity violations and for components that didn’t compile. Mentoring moved with it. I ask “where do we memoize here” far less often than “why does this render have to be pure.” For juniors that seems more useful: the purity rule carries to any code, while placing memo by hand was a skill about one specific tool.
What still bothers me
The compiler removed a signal. A useMemo in a diff used to mean something: someone profiled this, the spot is hot, give it a look. Memoization was a marker on the map. Now it’s uniform and invisible, and the code no longer shows its own hot paths. For a reviewer and a mentor that’s a loss, and I haven’t found a good replacement for the marker. The profiler shows what’s slow right now, not what once cost real effort and is holding because of it.
Second is the blind spot on cold leaves. That earlier post had a point about cargo cult. Memoizing a leaf that re-renders twice a session costs more than leaving it alone, and I kept those bare on purpose. The compiler memoizes everything, them included. The React team says the overhead is negligible, and in my measurements on the file manager it drowned in noise. But I can’t honestly prove it’s free across all the very cold, very numerous leaves, and I no longer get to choose.
And debugging. When everything is memoized and nothing is written by hand, a referential or stale bug is harder to locate: there’s no line in the source to point at. Dropping 'use no memo' on a suspect component became a routine debugging step for me, to tell whether the compiler or the logic is at fault. It helps, but needing that step at all isn’t something I like: part of the convenience’s cost moved into debugging.
So useMemo isn’t dead. I just stopped writing it by default. It still lives in two places: a component the compiler skips, and a value whose stable reference some piece of logic depends on. Each one is a decision now, and I check the compiler output to make it.