Web Development Bug Report

React Compiler silently skipped our slowest component: mutation during render and bail-outs

After enabling React Compiler we removed our manual useMemo calls, and the order table became slower than before. The compiler had bailed out of that component because it mutated an array during render. How we found which components were compiled, fixed the rule violations and verified the result.

Hannah Okafor Hannah Okafor @hannahokafor · January 06, 2026 · 7 min read · 27 Views
Banner: React Compiler silently skipped the slowest component because of mutation during render

When React Compiler reached its stable release, we enabled it in our back-office app. The compiler inserts memoization automatically, so we also started removing manual useMemo, useCallback and React.memo code, which the compiler makes unnecessary. Most pages got slightly faster. The orders page got noticeably slower: typing in the search box lagged, and the React Profiler showed the OrderTable component re-rendering on every keystroke, taking 60 to 90 ms each time.

Before our cleanup, OrderTable had used useMemo for its sorted rows. We had removed it, assuming the compiler would cover it.

Was the component compiled?

React DevTools shows a "Memo" badge next to components the compiler has optimised. OrderTable had no badge, while its siblings did. The compiler had skipped it.

The compiler skips, or "bails out of", components and hooks it cannot prove safe to optimise. It does not fail the build; it leaves the code as written. Our team had not enabled the lint rules that report those cases.

What the lint rule said

After enabling the compiler-related rules in eslint-plugin-react-hooks:

src/orders/OrderTable.tsx
  41:5  error  Mutating a value returned from a function whose return value should not be mutated.
               This value is used during render and mutation can cause unexpected behaviour.
               react-hooks/immutability

The code

export function OrderTable({ orders, sortBy, query }: Props) {
  const rows = orders.filter(o => o.customer.includes(query));

  // sort in place, then decorate
  rows.sort((a, b) => compare(a[sortBy], b[sortBy]));
  for (const row of rows) {
    row.displayTotal = formatMoney(row.total, row.currency);   // mutates the order objects from props
  }

  return <Table rows={rows} />;
}

Two problems: row.displayTotal = ... mutated objects that came from props, shared with other components and with the cache of our data-fetching library. And the pattern of mutating values during render is exactly what the compiler's rules forbid, because memoizing such code could change behaviour.

What we ruled out

  • The compiler not running at all. Other components in the same file tree had the badge, and the build output contained compiler-generated cache code for them.
  • Opt-out directives. There was no "use no memo" directive in the file.
  • The profiler being misleading. Timing with performance.now() around the render confirmed the cost.

Root cause

React Compiler bailed out of OrderTable because the component mutated props during render, which breaks the rules of React that the compiler relies on. The component ran uncompiled, and because we had removed its manual useMemo, the expensive filtering, sorting and formatting ran on every render.

Why the compiler needs the rules of React

The compiler memoizes values and JSX based on their inputs. That is only correct if components and hooks are pure during render: given the same props and state, they produce the same output, and they do not change values they did not create. Mutating props, values from hooks, or values captured by closures that might be cached breaks that assumption. When the compiler detects such patterns, it chooses not to optimise that function rather than risk changing behaviour.

Common bail-out causes:

Pattern Why it is a problem
Mutating props or state objects during render Memoized output could depend on stale or shared data
Reading or writing ref.current during render Refs are mutable and not tracked as inputs
Conditional hook calls Violates the rules of hooks
Mutating values after they are used in JSX The memoized JSX would capture an inconsistent value
Unsupported syntax in some versions The compiler declines to transform what it cannot analyse

Who is affected

Teams enabling React Compiler on an existing codebase, especially ones that remove manual memoization at the same time. Code written before the rules of React were enforced by tooling often contains small mutations during render: sorting props in place, adding derived fields to objects from a cache, pushing to arrays received from hooks. Each one makes the compiler skip the component silently.

Environment

Component Version / value
React 19.2
React Compiler 1.0, via babel-plugin-react-compiler in Vite
Lint eslint-plugin-react-hooks with compiler rules enabled after the incident
Data ~2,000 orders per page, search filter on keystroke

Stop mutating, derive new values

export function OrderTable({ orders, sortBy, query }: Props) {
  const rows = orders
    .filter(o => o.customer.includes(query))
    .toSorted((a, b) => compare(a[sortBy], b[sortBy]))
    .map(o => ({ ...o, displayTotal: formatMoney(o.total, o.currency) }));

  return <Table rows={rows} />;
}

toSorted returns a new array instead of sorting in place, and map creates new row objects instead of writing to the originals. The component is now pure, and the compiler optimises it: DevTools showed the badge, and rows was recomputed only when orders, sortBy or query changed.

Enable the lint rules everywhere

// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';

export default [
  reactHooks.configs['recommended-latest'],
];

The recommended preset of the hooks plugin includes the compiler-related rules in recent versions. Running it across the codebase found 37 violations in components and hooks. Most were simple; a few were real bugs, such as a hook that mutated an object returned by another hook and caused stale data in a sibling.

Do not remove manual memoization blindly

Our real mistake was the cleanup order. Removing useMemo is safe only where the compiler actually optimises the component. We now remove manual memoization only after the component shows as compiled and the lint rules pass for it.

Find bail-outs systematically

Besides the lint rules and DevTools, the compiler can report what it skipped. With the Babel plugin, a logger option prints events for each function it compiles or skips. We used it once during rollout to list all bail-outs:

// vite.config.js (excerpt)
react({
  babel: {
    plugins: [['babel-plugin-react-compiler', {
      logger: {
        logEvent(filename, event) {
          if (event.kind === 'CompileError' || event.kind === 'CompileSkip') {
            console.warn(`[compiler] ${filename}: ${event.kind} ${event.detail?.reason ?? ''}`);
          }
        },
      },
    }]],
  },
}),

Alternatives and trade-offs

  • Keep useMemo where the compiler bails out and fix the rule violation later. A valid stopgap; the lint rule makes the debt visible.
  • "use no memo" directive to opt a component out deliberately, for example during debugging. Not a fix.

How to test the fix

A Profiler-based test renders the table with 2,000 rows and simulates ten keystrokes in an unrelated input on the same page, asserting that OrderTable does not re-render:

let renders = 0;
render(
  <Profiler id="t" onRender={(id) => { if (id === 't') renders++; }}>
    <OrdersPage orders={fixtureOrders} />
  </Profiler>
);
const before = renders;
await user.type(screen.getByLabelText('Note'), 'hello');
expect(renders - before).toBeLessThanOrEqual(5);

Confirming in production

Typing in the search box stayed under 16 ms per keystroke. The OrderTable render cost when its inputs did not change dropped to near zero. Interaction to Next Paint for the orders page improved from about 280 ms to 90 ms at p75 in our real user monitoring.

Regression guard

  • The compiler lint rules run as errors in CI.
  • A codemod-style check in review: removing useMemo or React.memo requires that the component passes the lint rules.

Security implications

None directly. The mutation also caused a subtle data bug: displayTotal leaked into the shared cache and was sent back to the API in one form submission, which the server ignored. Pure render functions prevent that kind of accidental data flow.

Prevention checklist

  • Enable the React Compiler lint rules before or together with the compiler.
  • Never mutate props, state or hook return values during render.
  • Use non-mutating array methods such as toSorted and map.
  • Remove manual memoization only from components the compiler actually optimises.
  • Check DevTools badges or compiler logs to find bail-outs.

References

Add a label

#react #performance #eslint #react-compiler #memoization
Written by
Hannah Okafor @hannahokafor

I am a frontend engineer who has worked on dashboards, booking flows and design systems, mostly in React and TypeScript. I care about the parts of the web platform that frameworks hide: forms, cookies, rendering cost and what the browser actually does with our code. The bugs I write about usually passed every review and only appeared with production data: a map with a million points, a name with an accent in a PDF, a cookie that one subdomain never sees. Each post shows a minimal reproduction, why the browser or framework behaves that way, and a fix with a test.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.