← All posts

React Compiler: half the useMemos went, the useful ones stayed

A React 19 dashboard had 214 useMemo and useCallback calls. The compiler removed most of them; two edges stayed manual.

React Compiler: half the useMemos went, the useful ones stayed
Contents

In brief

A React 19 dashboard had accumulated 214 useMemo and useCallback calls.

The author counted them with a search on a Friday afternoon and then sat with the number. Nobody had sat down to plan that many wrappers. They piled up.

After the React Compiler was on, about 80 manual wrappers remained. Parts that were already memoized carefully did not get meaningfully faster. Screens nobody had optimized did.

The compiler silently skips a component that breaks the Rules of React. It also cannot stabilize an object that is recreated before compiled code ever sees it.

A draggable timeline is a separate story. That stutter was never a memoization problem.

What happened

The app is a React 19 dashboard. It has a large virtualized table, a handful of charts, and a timeline scrubber you drag.

The team ships product, not performance work. Wrappers went in defensively, after something already felt wrong.

A slow table got a useMemo. A flickering chart calmed down after a useCallback. A review asked for a wrap because the value was a new object on every render.

Two years later, half the components were wearing a bulletproof vest to the grocery store. The protection was on for an ordinary errand.

Turning the compiler on was boring. The author takes that as the best review.

On Next.js it is a config flag. On Vite it is one build plugin, babel-plugin-react-compiler.

They did not flip it on for the whole app at once. Annotation mode came first: only components with a "use memo" directive were compiled, feature by feature.

The narrow door was deliberate. Compile one area, see whether behavior still matches, then open the next door.

After two weeks they switched to the default mode. Components they still did not trust were marked "use no memo" and left on the old path next to compiled ones.

The useful step happened before any of that. They ran npx react-compiler-healthcheck@latest and the compiler-aware rules in eslint-plugin-react-hooks.

The health check said how many components the compiler could take. The linter said why it would skip the rest. That list, the author writes, was embarrassing.

A typical order list used to filter and sort inside two useMemo calls, wrap row selection in useCallback, and export the component through React.memo.

Afterwards it is ordinary .filter() and .sort() on a copy. The handler is passed through.

There is no dependency array to get wrong, and no stale closure hiding in useCallback. Nobody has to dig up why the value was wrapped.

The author treats that readability as the real win, ahead of any measurement. The code looks like what you would write on day one.

About 60% of the manual memoization went away across three small changes. Each change was one product area, with a measurement before and after.

If something regressed, it was obvious which deletion did it. A sweep of the whole dashboard would not leave that trail.

The count went from 214 calls to about 80.

The compiler also memoizes spots you cannot wrap by hand. Hooks cannot run after an early return.

A details screen was always awkward. Load the item, return the empty state immediately, build the summary below that.

A manual useMemo is illegal there. The compiler sees the whole function and can cache the work below the return without breaking the rule.

Why it matters

There was no 4× speedup. The author says they would not trust that claim on an app that was already wrapped by hand.

They measured three things and did not publish a finer scoreboard than this.

In the React DevTools profiler they replayed the same scripted interaction before and after each change: how many times the UI committed and how long that took.

The same script matters so a different path through the screen is not mistaken for the effect of a deletion.

INP came from real-user monitoring, one week before and one week after. A week on each side keeps a single noisy day from looking like a win.

The third check was bundle size. The compiler adds code, and a snappier screen should not hide a heavier main chunk.

Where memoization was already careful, nothing moved. Commit counts stayed the same. Durations stayed within noise.

The author treats that as the correct outcome, not a failure. The compiler does not owe a speedup to code that was already skipping extra renders.

The bundle grew slightly, a couple of percent on the main chunk. The article does not break that growth down by screen.

The wins came from screens nobody had optimized. A settings panel and a few other views re-rendered whole subtrees on every keystroke.

On mid-range Android phones, INP on those screens improved noticeably. The article does not give a tighter figure, so there is no number to add.

Automatic memoization helps most in the code people never had time to touch. Where wrappers were already in place, the measurements stayed inside the noise.

The honest reading is simple. No speedup where they had been careful. A real one where they had not. Less code overall.

The vest came off because most wrappers were no longer protecting anything. It did not come off because a stopwatch set a record.

Two cases on staging were things the compiler does not replace.

The first is a silent skip. The compiler does not crash when a component breaks the Rules of React. It skips that component and moves on.

A leaderboard sorted the scores array in place. .sort() mutates its input, and here that input is a prop.

The compiler will not optimize that safely, so the component stayed uncompiled. The fix is one line: sort a copy, [...scores].sort(...).

A silent skip is easy to miss because the build does not fail. It is tempting to assume the compiler is everywhere, while the leaderboard was left alone.

Until the lint list is read, the screen still works. It just works without automatic memoization.

The second case is a parent the compiler cannot reach. A legacy chart wrapper passes a fresh options object to a compiled child on every render.

The child can be compiled carefully. That does not help if the parent builds a new object every time: the child sees new props, and reference equality fails.

The compiler cannot fix a value that changes identity before it enters compiled code. At that boundary they put a manual useMemo back.

The useMemo sits where the compiler's guarantees have not started yet, not inside the chart. Where those guarantees stop, manual work starts again.

Separate from those two cases, the timeline scrubber still stuttered. It moves a playhead on every pointermove.

Most wrappers were gone, and the drag was still rough. The compiler makes rendering cheaper. It does not make rendering free.

They were pushing more than 60 state updates a second into React. No wrapper cancels the fact that the tree hears every movement of the pointer.

The drag left React state. A CSS variable updates from requestAnimationFrame, and React hears the value only when the gesture ends.

While the playhead follows the pointer, the picture updates without another walk of the component tree. React state gets the result only when the mouse button comes up.

High-frequency input was never a memoization problem. It is a third story in the article, not a third compiler failure.

In practice

The author would not start by deleting. The compiler coexists with existing useMemo calls, so there is no rush.

Run the health check first and treat the linter output as the migration queue. That queue is not a reason to empty the file on day one.

Then delete in small changes with a measurement, not in one bulk sweep. If the whole dashboard moves at once, a regression has nothing to compare against.

If the numbers do not move, the wrapper can go. If they regress, you have found a boundary or a rules violation, and the rollback is one area.

The order they recommend on an existing codebase:

  1. Delete nothing yet. Enable the compiler, run the health check, and fix the lint findings.
  2. Roll out gradually: annotation mode with "use memo", or "use no memo" on components you do not trust yet.
  3. Profile before and after each deletion. No movement means delete. A regression means a boundary or a rules violation.
  4. Keep manual memoization at the edges: an uncompiled parent, and third-party components that still need the same object reference.
  5. Take high-frequency input out of React state. Pointer moves, scrolling, and drags belong in a ref, a CSS variable, or requestAnimationFrame.

Keep a short list of boundaries. Wherever compiled code meets code the compiler did not touch, you are back in manual territory.

Takeaway

About 80 wrappers survived out of 214, and each survivor has a reason.

Those reasons were hard to see while the extra useMemo and useCallback calls were still standing next to them. After the cleanup they are easy to name.

The React Compiler takes the defensive wrappers that fall inside its rules. It does not promise a speedup on a dashboard that was already careful.

Two edges stay yours. A component that mutates its inputs in place gets skipped. An object rebuilt before compiled code sees it is still your job.

The timeline scrubber is the other lesson. The vest does not apply: more than 60 state updates a second need to leave React, not gain another wrapper.

Comments

Loading comments…