
React Compiler: the stale UI bug hiding between two components that both look correct
A React Compiler bug where one component bails out of memoization while its sibling silently gets memoized, freezing UI fed by a stable object reference. Root cause and fix.
If you’ve read the introduction to React Compiler on this blog, you know the pitch: the compiler memoizes for you, so most manual useMemo/useCallback become unnecessary. What that article doesn’t cover is a failure mode that only shows up once the compiler meets a real component tree: it can decide two components in the same data flow deserve opposite treatment, and that disagreement produces a UI that’s stale in exactly one spot while everything around it updates fine.
The symptom
A grid-like widget: a big data table plus a small footer showing a row count and pagination controls, both reading from the same state. Filter or sort the table, and the rows update immediately and correctly — but the footer keeps showing the old count and the old page, as if it never re-rendered at all. Refreshing the page fixes it. Refactoring the footer to accept the same values as props (instead of reading them from context) also fixes it. Neither fact points anywhere near a real explanation.
Confirm it’s the compiler
Before reaching for a minimal reproduction, run the cheapest test available: temporarily remove the React Compiler plugin from your build config (or scope it away from the affected files) and reload. If the footer starts updating correctly again, you’ve isolated the cause to the compiler’s memoization — not to the state library, not to a stale closure, not to a missing dependency somewhere. That one toggle turns “somewhere in this feature” into “somewhere in what the compiler decided to memoize,” which is most of the diagnostic work done in a few seconds.
A minimal reproduction
Strip the scenario to its essentials: a hook wrapping a stateful object that’s mutated in place rather than replaced, and two components consuming it — one directly, one through context.
// A common pattern in stateful third-party hooks: the returned object's
// identity stays stable across renders; only its internals change.
function useDataTableState(rows) {
const table = useRef(null);
if (table.current === null) {
table.current = createTableInstance(rows);
}
table.current.setRows(rows); // mutates in place, same reference returned
return table.current;
}
function DataTable({ rows }) {
const table = useDataTableState(rows);
return (
<TableContext.Provider value={table}>
<table>{/* renders table.getVisibleRows() */}</table>
<TableFooter />
</TableContext.Provider>
);
}
function TableFooter() {
const table = useContext(TableContext);
return <footer>{table.getRowCount()} rows · page {table.getState().pageIndex}</footer>;
}
DataTable renders correctly after every update. TableFooter freezes on the first values it ever saw.
Root cause
React Compiler statically analyzes each component and decides, function by function, whether it’s safe to wrap its output in the memoization equivalent of useMemo. When it can’t prove that a hook’s return value behaves like immutable, referentially-stable state — which is exactly the case for useDataTableState, since it mutates and returns the same object — the compiler bails out and leaves that component unmemoized. That’s the correct, conservative call: better to re-render every time than to memoize against a value that lies about having changed.
The problem is that this bailout doesn’t propagate through context. TableFooter doesn’t call useDataTableState — it only calls useContext, an operation the compiler has no reason to distrust. From its perspective, TableFooter is an ordinary component with an ordinary dependency, so it happily memoizes it. And since table is the same object reference across renders (only its internal state mutated), the memoized TableFooter sees “nothing changed” and returns its cached output — even though table.getRowCount() would return something different if actually called.
So you end up with two components fed by the same underlying state, one correctly bailed out of memoization, the other incorrectly memoized, because the compiler’s hazard detection only looks at how a component obtains its data — a direct hook call — not at what that data actually is.
⚠ Attenzione
This is easy to miss because nothing errors and no lint rule fires by default: useContext is a
perfectly normal, compiler-safe call. The hazard lives one level up, in whichever hook produced
the value now flowing through the context, and the compiler has no way to see that far.
The fix
Opt the consumer out of compiler memoization explicitly, with the same directive the compiler already applies internally when it bails out on its own:
function TableFooter() {
'use no memo';
const table = useContext(TableContext);
return <footer>{table.getRowCount()} rows · page {table.getState().pageIndex}</footer>;
}
That’s the whole fix. 'use no memo' tells the compiler “don’t touch this component,” restoring the plain re-render-on-every-parent-update behavior that was implicitly correct before the compiler ever got involved.
The rule of thumb
Any component that reads — directly or through context — a value produced by a hook the compiler has excluded from memoization needs the same exclusion, and you have to add it by hand: the compiler doesn’t trace the hazard across a context boundary or a prop. In practice, this means:
- Treat “incompatible with the compiler” as a property of the value, not just the component that first produces it. If a hook returns something with a mutable, stable-reference shape (common in table/grid libraries, some state machines, anything backed by
useRefunder the hood), every consumer downstream of it — direct or via context — is a candidate for the same'use no memo'. - Don’t rely on the absence of a compiler warning as proof a component is safe to memoize. The warning fires where the hazard is created, not everywhere the hazardous value is read.
- If you control the hook, the more durable fix is to make it compiler-friendly instead of opting components out one by one: return a new object (or bump a version counter alongside it) whenever the data actually changes, so referential equality means what the compiler assumes it means.
💡 Consiglio
To catch this class of bug before it ships, turn on “Highlight updates when components render” in React DevTools while you exercise the feature. A component that should update but doesn’t flash is a much faster signal than tracing stale values through a debugger.

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.