AL+

Language: RO (română) EN (English)

Email us: paul@alplustech.com

React Compiler and useMemo: audit skipped code before deleting useMemo

React Compiler skips components that break the Rules of React, without failing the build. In an inherited app, use it as an audit first and delete useMemo last.

Citiți articolul în română

React Compiler did not make React performance free. What it did was make the Rules of React enforceable. A codebase that already followed those rules gets automatic memoization almost for nothing. A codebase full of mutation and effect workarounds gets much less, because the compiler quietly leaves the offending components alone. Whether skipped components are also slow is a hypothesis to check with profiling, not a compiler guarantee. For a team that inherits an existing React app, the compiler is most useful first as an audit of where the code breaks React's model. Deleting useMemo calls is the last step of that work, not the first.

What was released, and what it promised

On 15 May 2024, at React Conf, the React team announced that React Compiler was open source and published an experimental version to try. The pitch was simple: a build-time tool that memoizes components and hooks automatically, so developers no longer have to write useMemo, useCallback and React.memo by hand.

The conditions came with it, in the compiler working group's own threads. Two weeks before the conference, the team wrote that the compiler statically validates the Rules of React and, when it finds a violation, "safely skips over the offending component or hook and continues compiling the rest of your code" (30 April 2024). Two days before it, the library compatibility notes added the other half: where the compiler cannot tell whether code is valid, for example when it calls an external function, it "assumes that code is following the rules" (13 May 2024).

So from the first day the compiler made two kinds of decision without telling the application's owner much: skip what it can see is wrong, and trust what it cannot see. That was not how much of the first reaction read it. One of the top comments in the Hacker News thread on launch day called it "a magic wand that will speed up a huge amount of existing websites/apps without major code changes".

The project then moved quickly. A beta followed on 21 October 2024, with support for React 17 and 18 through a runtime package, and version 1.0 on 7 October 2025, with the compiler's lint rules moved into eslint-plugin-react-hooks.

Skipping is silent by design

The default behaviour matters more than the announcement. The panicThreshold option defaults to 'none': components that cannot be compiled are skipped, the build continues, and those components "run normally". The documentation recommends that setting for every production build.

That is the right default for a tool that must never break a build. It also means that turning the compiler on in an old codebase produces a successful build and a working app, with no indication of how much of it was optimized. Skipped functions can include rule violations and other exclusions. Inspect the diagnostics before deciding which need a correctness fix.

A typical case is a component that decorates its input in place:

function OrderList({ orders }) {
  for (const order of orders) {
    order.label = `${order.number} (${order.customer})`;
  }
  return orders.map((order) => <OrderRow key={order.id} order={order} />);
}

This mutates objects that belong to the parent. The compiler's own test suite has this exact pattern as an error case: "Modifying component props or hook arguments is not allowed. Consider using a local variable instead." The fix is small and is a correctness fix before it is a performance one:

function OrderList({ orders }) {
  const rows = orders.map((order) => ({
    ...order,
    label: `${order.number} (${order.customer})`,
  }));
  return rows.map((row) => <OrderRow key={row.id} order={row} />);
}

Once the component no longer mutates its props, the compiler can memoize it. Before that, it is left as written, and nobody is told unless they look.

The worse case: code the compiler trusts

Skipped components are a missed opportunity. The more expensive problem is code the compiler compiles because it could not detect the violation. The debugging guide says most issues people hit are runtime issues of exactly this kind, and names the common cause: code that "relies on memoization for correctness". Effects that depend on an object keeping the same reference, dependency arrays that need stable values, and caching based on reference checks can over-fire, loop or miss updates when memoization changes.

That is why the 1.0 announcement advises leaving existing useMemo and useCallback in place in existing code, "removing it can change compilation output", or testing carefully before removing it. I also recommend pinning the compiler to an exact version while test coverage is weak, because a future version may memoize differently and change when those effects fire. In an inherited codebase, weak coverage is the usual starting point.

The strongest argument against this

The React team's own guidance points the other way. The working group's rollout advice is "make it work, make it right, make it faster": enable the compiler, check that the app still works, measure, and only then fix the violations the linter reports. It says plainly that fixing violations first is "absolutely not necessary" to see benefits.

Meta's numbers support that order. The beta post reports that Meta onboarded several major web apps, including Facebook and Threads, with few code changes in a monorepo of more than 100,000 components, and that only about 8% of its React pull requests used manual memoization while those took 31 to 46% longer to write. The 1.0 post reports initial loads and navigations on the Quest Store improving by up to 12%.

I think that advice is right for the codebases it describes. Meta's apps had been, in the team's words, "heavily hand tuned and optimized by Meta engineers and React experts over the years", in a company that wrote the rules. An app that has passed through several teams, none of them running the linter, is a different population. Enabling the compiler there is still worth doing, but a working build tells you very little, and only profiling can show whether the slow paths overlap with skipped components. Valid components can be bottlenecks; violations need not be expensive.

An order of work for an inherited app

For a team that has just taken over a React codebase, this is the sequence I would argue for:

  1. Run the linter before the compiler. The compiler's rules have been usable without the compiler since the beta, and now ship in eslint-plugin-react-hooks. The report is a map of where the app breaks React's model, and each violation is a possible bug with or without the compiler.
  2. List what the compiler would skip. The logger option distinguishes CompileError (skipped due to errors) from CompileSkip (skipped for other reasons). Record both, alongside CompileSuccess, and inspect the reason rather than treating every skip as a rule violation. Put that list next to a profiler trace of the slow screens.
  3. Fix rule violations where the two lists overlap, with a test around each behaviour you touch. These are correctness fixes, and they are worth doing even if the compiler never ships.
  4. Enable the compiler in stages. The incremental adoption guide covers per-directory overrides, opt-in with "use memo" and runtime gating behind a feature flag. Use "use no memo" only as a temporary, commented exclusion.
  5. Remove manual memoization last, one component at a time, and only where tests cover the effects that depend on it.

The audit produces value even if the performance gain turns out to be small. A list of components that mutate props or read refs during render is a list of places where the app is fragile today.

Where this does not apply

New apps should simply start with the compiler on. Since 1.0, Expo SDK 54 enables it by default and the Vite and Next.js starters offer compiler-enabled templates, and a codebase written with the linter from the first day has little to audit.

Nor is the compiler the answer when the slowness is not re-rendering. The documentation says its memoization is primarily focused on update performance, and that it only memoizes components and hooks, without sharing results across them. A slow API, an oversized bundle or an expensive function called from many components needs a different fix.

Two years on

Most of the 2024 discussion asked whether useMemo could be deleted. Two and a half years later the more useful question is what the compiler refused to compile, and why. The compiler did not take responsibility for performance away from the team that owns the code. Its diagnostics help identify rule violations and other compilation limits. The team still has to interpret them, profile the result and own the fixes.

Sources