DEV Community

Faisal Mujtaba
Faisal Mujtaba

Posted on Edited on

We Deleted Half Our useMemos (and Kept the Ones That Mattered)

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:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-react-compiler', {}]],
      },
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

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:

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]
  );

  return (
    <ul>
      {sorted.map((o) => (
        <OrderRow key={o.id} order={o} onSelect={handleSelect} />
      ))}
    </ul>
  );
}

export default React.memo(OrderList);
Enter fullscreen mode Exit fullscreen mode

And the "after":

function OrderList({ orders, filter, onSelect }: Props) {
  const sorted = orders
    .filter((o) => o.status === filter)
    .sort((a, b) => b.createdAt - a.createdAt);

  return (
    <ul>
      {sorted.map((o) => (
        <OrderRow key={o.id} order={o} onSelect={onSelect} />
      ))}
    </ul>
  );
}

export default OrderList;
Enter fullscreen mode Exit fullscreen mode

It 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.

We 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.

The 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:

function Details({ id }: { id: string }) {
  const item = useItem(id);
  if (!item) return <Skeleton />;

  // Before the compiler, memoizing this meant restructuring the component.
  // Now it's just code.
  const summary = buildSummary(item);
  return <Summary data={summary} />;
}
Enter fullscreen mode Exit fullscreen mode

How we measured (and what we found)

I'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.

We measured three things:

  • Commit counts and render durations in the React DevTools Profiler, replaying the same scripted interaction (filter, sort, select a row, change date range) before and after each PR.
  • INP from our real-user monitoring, over a week before and a week after.
  • Bundle size, because the compiler adds some code.

The 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. The compiler had matched what we'd done by hand, minus the maintenance burden. Bundle size went up slightly, a couple of percent on the main chunk.

The wins came from places we'd never memoized. A settings panel and a few "nobody profiles this" screens had been quietly re-rendering whole subtrees on every keystroke. Nobody had noticed because they were fast enough on our laptops. On the mid-range Android devices our users actually have, INP on those screens improved noticeably. Auto-memoization is great at the code nobody had time to optimize.

So the honest summary is: no speedup where we'd been careful, a real one where we hadn't, and a lot less code overall.

What broke in staging

Two things broke, and neither was the compiler's fault in the way I first assumed.

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. That's the right design, but it means a component can look "compiled" in your head and not be. We had a component doing this:

function Leaderboard({ scores }: { scores: Score[] }) {
  // mutates the prop array in place
  const ranked = scores.sort((a, b) => b.value - a.value);
  return <RankedList items={ranked} />;
}
Enter fullscreen mode Exit fullscreen mode

.sort() mutates its input, and here that input is a prop. It had "worked" for two years because a useMemo upstream happened to mask the problem. The compiler can't safely optimize that, so it skipped the component. The component's children were compiled, but they now received a freshly sorted array reference each render. On the profiler, the whole thing lit up like a Christmas tree.

The fix was one line ([...scores].sort(...)), but finding it took a day because nothing was wrong in the usual sense. The lesson: the DevTools "Memo ✨" badge is your friend. If a component you expected to be compiled doesn't have it, the lint output will tell you why.

Second: a parent outside the compiler's reach. We have a legacy chart wrapper that we haven't migrated. It's excluded from compilation and passes a fresh options object to a compiled child on every render. The child compares props by reference, so it re-rendered every time, and the compiler can't fix a prop that changes identity before it ever reaches the compiled code.

We fixed this one the old-fashioned way, at the boundary:

// In the file the compiler doesn't touch, memoization is still our job.
function LegacyChartWrapper({ series, theme }: Props) {
  "use no memo";

  const options = useMemo(
    () => ({ series, palette: theme.palette, animation: false }),
    [series, theme.palette]
  );

  return <CompiledChart options={options} />;
}
Enter fullscreen mode Exit fullscreen mode

This is the first case where a manual useMemo earned its place. Anywhere the compiler's guarantees stop, yours start again. Code the compiler skips, or code that hands props to components that depend on referential equality, can still need manual memoization.

The one that was never a memoization problem

Our timeline scrubber is a draggable handle that updates a playhead position on every pointermove. Before the compiler, we'd wrapped everything around it in memo and useCallback to keep the drag smooth. After the compiler, we deleted most of that. And the drag still stuttered on slower devices.

Of course it did. The compiler makes rendering cheaper; it doesn't make rendering free. We were pushing 60+ state updates per second through React, and no amount of auto-memoization changes that. Each update still schedules a render, still runs the component, and still reconciles.

The fix was to stop using React state for the drag entirely:

function Scrubber({ onCommit }: { onCommit: (t: number) => void }) {
  const handleRef = useRef<HTMLDivElement>(null);
  const frame = useRef(0);

  function onPointerMove(e: React.PointerEvent) {
    const x = e.clientX;
    cancelAnimationFrame(frame.current);
    frame.current = requestAnimationFrame(() => {
      handleRef.current?.style.setProperty('--x', `${x}px`);
    });
  }

  function onPointerUp(e: React.PointerEvent) {
    onCommit(positionToTime(e.clientX)); // React hears about it once
  }

  return (
    <div
      ref={handleRef}
      className="scrubber-handle"
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The pointer movement goes straight to a CSS variable, outside React's render cycle, and React only finds out when the drag ends. High-frequency input like this was never a memoization problem, and the compiler didn't change that. This is the code I'm proudest of from the whole migration, and it has nothing to do with the compiler.

What I'd do differently

  • Run the healthcheck and lint rules before touching anything. We did, but I'd treat the lint output as the real migration backlog. Every violation it lists is a component the compiler will silently skip.
  • Delete memoization in small PRs with measurements. Bulk deletes make regressions impossible to bisect.
  • Keep a list of the boundaries. Wherever compiled code meets uncompiled code, you're back in manual territory.

My recommendation

If you're enabling the compiler on an existing codebase, here's the order I'd follow:

  1. Don't delete anything yet. Enable the compiler, run the healthcheck, and fix the lint violations first. The compiler happily coexists with your existing useMemo calls, so there's no rush.
  2. Roll out gradually. Start in annotation mode, or opt out the risky components with "use no memo", and widen coverage as you trust it.
  3. Profile before and after each deletion. If the numbers don't move, delete. If they regress, you've found a boundary or a rule violation worth understanding.
  4. Keep manual memoization at the edges. That means uncompiled parents, third-party components that depend on reference equality, and any value you deliberately want as a stable effect dependency.
  5. Take high-frequency input out of React state. Pointer moves, scroll handlers, and drag interactions belong in refs, CSS variables, or requestAnimationFrame. The compiler can't fix a render you shouldn't be triggering.

We went from 214 manual memos to about 80. Every one of the survivors 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)