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.
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
useMemowhere 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
useMemoorReact.memorequires 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
toSortedandmap. - Remove manual memoization only from components the compiler actually optimises.
- Check DevTools badges or compiler logs to find bail-outs.
References
- React Compiler documentation: https://react.dev/learn/react-compiler
- Rules of React: https://react.dev/reference/rules
- eslint-plugin-react-hooks: https://react.dev/reference/eslint-plugin-react-hooks
- MDN, Array.prototype.toSorted(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted
Add a label
Embed this post
<iframe src="https://www.hibug.me/react-compiler-component-not-optimized" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Related Stories
React 19 useFormStatus pending is always false: the hook must live inside the form
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.