React Compiler ships with React 19 and automates the memoization that useMemo, useCallback, and React.memo used to require by hand. It went stable at version 1.0 on October 7, 2025 (React Compiler v1.0, React team). Since then, teams enabling it on existing codebases keep hitting the same wall: forms stop updating, tables stop reacting. The fix that shows up first in every search result, the "use no memo" directive, turns out to be a debugging tool the React team explicitly does not want you shipping long-term.
This post explains why the conflict happens and exactly how "use no memo" works and where it breaks down. It also covers the compiler-resilient patterns that let you keep React Hook Form and TanStack Table working without permanently opting out of the compiler.
- React Compiler memoizes based on strict reference equality (
!==), not deep equality. Libraries that mutate a stable object’s internals, like React Hook Form’suseForm()control object, get cached forever and appear “frozen.” "use no memo"disables compilation for one function or file. It must be the first statement in the function body, in single or double quotes, and the React docs call it “a temporary debugging tool, not a permanent solution” (react.dev).- The durable fix is swapping mutable reads for subscription hooks:
watch()→useWatch(),formState→useFormState(), plainregister()→<Controller>. A community regression suite (timkindberg/rhf-compiler-compat) found this closes the gap from 52% to 100% test pass rate across compiler and library versions.
How React Compiler Decides What to Memoize
React Compiler is a Babel plugin (babel-plugin-react-compiler) that runs at build time. It parses your component into an AST, lowers that into a High-Level Intermediate Representation (HIR), and converts the HIR into Static Single Assignment (SSA) form. That SSA form is what the compiler analyzes to trace every variable, object mutation, and dependency across your component’s control flow (Entry Point through Babel; the pipeline’s actual entry point and pass ordering are defined in the compiler’s own source, babel-plugin-react-compiler on GitHub). Where it finds a safe boundary, it injects a per-component cache slot (commonly named _c or accessed via useMemoCache) and wraps the computation so it only re-runs when a dependency’s reference changes.
That last part is the whole story: the compiler checks !== on references, not the contents behind them.
The compiler works from three invariants, all restated from React’s own rules, not new requirements it invented:
| React Rule Invariant | Compiler Expectation | Legacy Pattern Violation |
|---|---|---|
| Component and hook purity | Identical props/state yield identical output with no render side effects | Reading non-reactive external values, or mutating state, during render |
| Return value immutability | A hook’s return value only changes by returning a new reference | Mutating properties inside a stable, long-lived container object |
| Ref isolation | ref.current is never read or written during render | Reading useRef contents inside render logic to compute UI state |
Code that already followed these rules gets faster for free. Code that quietly violated them, and worked anyway before the compiler, breaks in a specific and reproducible way once memoization is added on top.
The Root Cause: Interior Mutability
The libraries that clash hardest with React Compiler, React Hook Form (RHF), TanStack Table, TanStack Virtual, and MobX, share one design: interior mutability. Each returns a stable, top-level object reference and then updates that object’s internal properties imperatively, without ever changing the reference itself.
React Hook Form’s useForm() builds a control object stored in a useRef (the library’s own source calls it _formControl, see useForm.ts on GitHub), so form is the exact same reference on every render (Understanding Compatibility Issues from React Hook Form’s Internal). When a field changes, RHF updates that object’s internal state directly. The compiler sees form hasn’t changed reference and assumes anything derived from it is still valid, so it never recomputes. This produces three distinct failure modes:
- Stale reads from a mutable container.
form.watch("email")gets compiled into the equivalent ofuseMemo(() => form.watch("email"), [form]). Becauseformnever changes reference, the memoized value is computed once and never again. React’s ownincompatible-libraryESLint rule documentation usesuseForm().watch()as its canonical example of this exact pattern (react.dev: incompatible-library), and independently corroborates the mechanism described here. - Bypassed Proxy getters.
formStateusesObject.definePropertygetters that register a field subscription as a side effect of being read. DestructureformState.errorsinside a component the compiler memoizes, and that render output is cached before the getter ever fires again, so the subscription is never (re-)registered and validation messages stop updating. - Ref reads during render. RHF’s internals read
ref.currentin several places during render, which directly violates the ref-isolation invariant above.
React core team member Joe Savona confirmed the mechanism directly in a GitHub issue about a watch() field that stopped updating: “It looks like the watch() API isn’t using state to tell React that something has changed, which goes against React’s rules around idempotency. The useWatch() API looks like it does use internal state to tell React when changes occur, so that should work just fine (both with useMemo and React Compiler)” (react/react#29144).
A related failure hits reset(). RHF’s internal _reset algorithm clears its field registries and expects the next render to re-run register(name) for every input, which re-establishes the DOM binding. Under compiler memoization, that JSX subtree is cached, register(name) never re-runs, and the input loses its binding to the form state even though the reset itself succeeded internally (React Compiler Broke Our Tables, And “use no memo” Is Not the Fix).
Which Libraries Break, and Which Don’t
| Library / API | Breaks under React Compiler? | Why |
|---|---|---|
React Hook Form watch(), formState, plain register() + reset() | Yes | Interior mutability, Proxy-getter bypass, ref reads |
TanStack Table useReactTable() | Yes | Returns a mutable table instance with a stable reference |
TanStack Virtual useVirtualizer() | Yes | Same interior-mutability pattern |
MobX observer() | Yes | Observable-mutation model conflicts with reference-based caching |
| Zustand, Jotai, TanStack Query | No | Built on useSyncExternalStore or return a new reference whenever data changes |
The libraries that survive share one trait: they return a new reference the moment their underlying data changes, which is exactly what the compiler’s !== check is designed to catch.
MobX sits in a different position from the other two. Its whole programming model is built on observable objects that notify subscribers when their internal state mutates, the opposite of “return a new reference on change.” observer()-wrapped components read observable properties directly during render. The compiler treats that read as pure and cacheable, so the observable’s own change-notification system has no way to force a re-render past that cache. There is no drop-in hook swap here the way useWatch() fixes React Hook Form. Teams running MobX under React Compiler either scope the compiler away from MobX-observing components with overrides (covered below) or wait for first-class compiler support from the MobX maintainers.
The “Use No Memo” Directive: Syntax and Gotchas
"use no memo" (alias "use no forget") tells the compiler to skip a function entirely, leaving its AST exactly as authored. It can be placed at the top of a single function or at the top of a file to cover every export in it.
// Function-level opt-out
function LegacyFormHandler() {
"use no memo";
const form = useForm();
return <form>{/* runs without compiler optimization */}</form>;
}// File-level opt-out (must be the absolute first statement in the file)
"use no memo";
export function HeavyFormOne() { /* ... */ }
export function HeavyFormTwo() { /* ... */ }Three syntax rules are strict enough to silently fail if you miss them:
- It must use single or double quotes. Template literals (backticks) are ignored by the parser.
- It must be the first statement in the function body or module, before any other code (leading comments are fine).
- It takes precedence over every global
compilationModesetting, including'all','infer', and'annotation'(react.dev).
During AST traversal, the Babel plugin checks the function’s directive list for a matching string. If found, it skips HIR lowering, SSA conversion, and cache injection for that scope entirely, so the function compiles to itself.
React’s own documentation is explicit that this is meant to be temporary: “It’s intended as a temporary debugging tool, not a permanent solution.” Treat it as a scoped escape hatch, not an architecture decision.
The Directive-Sprawl Pitfall
A common mistake is placing "use no memo" only on the parent component that calls useForm(), assuming child components automatically inherit the opt-out. They don’t. If a child component calls register(name) or useFormContext() and the compiler does memoize that child, the child’s JSX output stays cached. When the uncompiled parent re-renders, it hands the compiled child the same control object reference as before, so the child skips re-rendering entirely (React Compiler support · react-hook-form Discussion #12524).
In practice, "use no memo" has to be applied to the host component, every compiled descendant that reads form state, and every intermediate parent whose memoized JSX would otherwise wall off those descendants. On a form-heavy screen with several nested field components, that’s directive sprawl: the exact maintenance burden the React team warns about.
The isolation fix: wrap the hook call itself, not the whole component tree.
// src/hooks/useFormCompat.ts
import { type UseFormProps, type FieldValues, useForm } from 'react-hook-form';
export function useFormCompat<
TFieldValues extends FieldValues = FieldValues,
TContext = any
>(props?: UseFormProps<TFieldValues, TContext>) {
'use no memo';
return useForm<TFieldValues, TContext>(props);
}This confines the opt-out to the hook execution frame. Parent and sibling components elsewhere in the tree stay compiled and optimized; only the code that directly touches the mutable form object is exempted.
The Durable Fix: Compiler-Resilient Patterns
"use no memo" buys time. The permanent fix is replacing every render-time read of a mutable container with an explicit subscription hook, because subscription hooks use internal React state to signal changes, exactly the mechanism the compiler expects.
Replace watch() with useWatch():
// Breaks under React Compiler: form object never changes reference
function UnstableWatchChild() {
const { watch } = useFormContext();
const formValue = watch("fieldName");
return <span>{formValue}</span>;
}
// Compiler-resilient: explicit subscription
import { useWatch } from "react-hook-form";
function StableWatchChild({ control }: { control: Control<FormValues> }) {
const formValue = useWatch({ control, name: "fieldName" });
return <span>{formValue}</span>;
}Replace formState destructuring with useFormState():
// Breaks: Proxy getter never fires under memoization
function UnstableFormState() {
const { formState: { isDirty } } = useFormContext();
return <button disabled={!isDirty}>Submit</button>;
}
// Compiler-resilient: explicit hook subscription
import { useFormState } from "react-hook-form";
function StableFormState({ control }: { control: Control<FormValues> }) {
const { isDirty } = useFormState({ control });
return <button disabled={!isDirty}>Submit</button>;
}Replace plain register() bindings with <Controller> so inputs survive reset():
// Unbinds after reset() under compiler memoization
<input {...register("email")} />
// Compiler-resilient: Controller maintains its own subscription
import { Controller } from "react-hook-form";
<Controller
name="email"
control={control}
render={({ field }) => <input {...field} />}
/>| Legacy Pattern | Compiler-Resilient Alternative | Why It Works |
|---|---|---|
const val = watch('field') | const val = useWatch({ name: 'field', control }) | Explicit hook subscription replaces a snapshot container read |
const { isDirty } = formState | const { isDirty } = useFormState({ control }) | Bypasses the Proxy-getter timing problem entirely |
reset(newData) + register() | <Controller name="field" control={control} /> | Keeps input refs bound after _fields is cleared |
getValues('field') during render | Move to event handlers, or use useWatch | Keeps render pure; snapshot reads belong in callbacks |
table.getHeaderGroups() rendered directly | Pass extracted plain-array props to children | Bypasses the stable-instance reference check |
Fixing TanStack Table’s Interior Mutability
useReactTable() has the same problem as useForm(): it returns one stable table instance and mutates its internals as columns, sorting, and visibility change. React’s own incompatible-library rule uses useReactTable() as a canonical example of an API that “returns functions which cannot be memoized without leading to stale UI” (react.dev: incompatible-library). One team that hit this in production found header groups and column-visibility toggles silently going stale after enabling the compiler (React Compiler Broke Our Tables, And “use no memo” Is Not the Fix).
The same principle that fixes React Hook Form applies here: stop passing the mutable instance itself into memoized descendants, and pass the derived plain data instead.
// Breaks: child receives the same table reference every render,
// so a memoized child never sees the updated header groups
function TableShell({ table }: { table: Table<RowData> }) {
return <TableHeader table={table} />;
}
// Compiler-resilient: extract a plain array before it crosses
// a memoization boundary
function TableShell({ table }: { table: Table<RowData> }) {
const headerGroups = table.getHeaderGroups(); // read on every render, not cached
return <TableHeader headerGroups={headerGroups} />;
}Reading table.getHeaderGroups() directly in the parent, on every render, and handing the child a fresh plain array keeps the data flow compiler-safe without touching TanStack Table’s internals at all.
For state outside form and table libraries, useSyncExternalStore is the compiler-native pattern: Zustand already uses it internally, which is why Zustand-backed state doesn’t need any of these workarounds. A community regression suite, timkindberg/rhf-compiler-compat, tracked how much these patterns actually close the gap across compiler and library versions:
The trajectory matters more than any single number: compatibility improved sharply between the React 18 release-candidate compiler and the React 19 1.0 GA release, and again once RHF shipped an 8.0 beta built with the compiler in mind. Pin your versions and re-test after any bump. Two patterns were still the last to fail, even on the upgraded 1.0-GA stack: formState.errors/isDirty read through useFormContext() in a child component, and register() combined with reset(). Both line up exactly with the directive-sprawl and Proxy-getter mechanisms described above.
Build-System Configuration and Tooling Enforcement
Beyond directive-level fixes, babel-plugin-react-compiler exposes configuration to scope where the compiler runs at all:
// babel.config.js
module.exports = {
plugins: [
[
'babel-plugin-react-compiler',
{
compilationMode: 'infer',
target: '19',
gating: {
source: 'src/config/featureFlags',
importSpecifierName: 'isCompilerEnabled',
},
panicThreshold: 'none',
},
],
],
};| Option | Values | Purpose |
|---|---|---|
compilationMode | 'infer' | 'annotation' | 'all' | 'infer' targets component/hook patterns automatically; 'annotation' compiles only functions explicitly marked "use memo"; 'all' compiles every top-level function |
target | '17' | '18' | '19' | Targets before '19' need the separate react-compiler-runtime package |
gating | { source, importSpecifierName } | Wraps compiled code behind a runtime flag for staged rollout |
panicThreshold | 'none' | 'critical' | 'all' | 'none' skips components the compiler can’t safely handle instead of failing the build |
For large codebases, Babel’s overrides lets you enable the compiler directory-by-directory instead of all at once:
// babel.config.js
module.exports = {
plugins: [],
overrides: [
{
test: './src/features/modern/**/*.{js,jsx,ts,tsx}',
plugins: [['babel-plugin-react-compiler', { compilationMode: 'infer' }]],
},
{
test: './src/legacy/forms/**/*.{js,jsx,ts,tsx}',
plugins: [],
},
],
};On the linting side, eslint-plugin-react-hooks now ships compiler-aware rules, including an incompatible-library rule that flags known-incompatible APIs like watch() and useReactTable() before you ever run the build, using nearly the same code examples shown above (react.dev: incompatible-library):
{
"plugins": ["eslint-plugin-react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/incompatible-library": "warn"
}
}The exact rule prefix has moved release to release as the compiler’s ESLint integration matured: some plugin versions use react-hooks/*, older ones use react-compiler/*. Check the “Recommended Rules” table on react.dev’s eslint-plugin-react-hooks reference against the version you actually install before copying a config wholesale.
Running this linter doesn’t require the compiler to be installed at all, which makes it the cheapest first step for any team still deciding whether to adopt React Compiler.
A Three-Phase Migration Roadmap
- Tooling baseline and audit. Set
panicThreshold: 'none'so uncompilable modules degrade gracefully instead of breaking the build. Installeslint-plugin-react-hooksacross the whole repo to surface existing Rules-of-React violations before you touch a single component. Useoverridesorgatingto scope compilation to modern feature directories first. - Granular legacy isolation. For existing form-heavy screens, wrap
useForm()(and equivalent calls in other libraries) inside a compatibility hook likeuseFormCompat. This confines"use no memo"to the hook frame and avoids directive sprawl across the component tree. - Standardize resilient authoring for new work. Stop writing
watch(), rawformStatedestructuring, and plainregister()bindings in any new code. Default touseWatch(),useFormState(), and<Controller>so new features never need the opt-out in the first place, and remove"use no memo"from a module only after its reset, conditional-field, and field-array tests pass with the compiler on.
Next Steps
Start with the audit, not the directive. Run eslint-plugin-react-hooks across your codebase before you enable the compiler anywhere; it will tell you exactly which components are going to break and why, without requiring you to install the compiler first. From there, isolate legacy forms behind a compatibility hook, and treat every new component as an opportunity to write the subscription-hook pattern from day one instead of inheriting the mutable-container habits that made this migration necessary.
FAQs
formState or calls register() will still get memoized independently. You need the directive on the host component, every compiled descendant that touches form state, and any parent whose memoized JSX would otherwise block those descendants from re-rendering. watch() is still hardcoded as a bailed-out API in the compiler’s known-incompatible list, so useWatch() remains the safer default even after upgrading. useSyncExternalStore or return a new object reference whenever their underlying data changes, which already matches how the compiler expects state to behave. Related Citations & References
- DEWill React Compiler Make Manual Memoization Obsolete? — Things to Know Before Adopting It – DEV Community
- ZEForms Break with React Compiler — Understanding Compatibility Issues from React Hook Form's Internal Design
- REConfiguration – React
- MEReact Compiler Broke Our Tables And Use No Memo Is Not The Fix F8d849f6eb79
- FRHow to Use React Compiler – A Complete Guide
- YOReact Compiler, How Does It Work? [1] – Entry Point through Babel Plugin | 장용석 블로그
- GICorrect behaviour for apps using react-compiler · Issue #12298 · react-hook-form/react-hook-form · GitHub
- GIReact Compiler support · react-hook-form · Discussion #12524 · GitHub
- STWhat's New in Next.js 16? How to Build Faster, Ship Smarter
- BLI let React Compiler handle memoization: Here's what actually broke – LogRocket Blog
- DAI let React Compiler handle memoization: Here’s what…
- REReddit
- RE'use no memo' directive – React
- GIreact/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts at main · react/react · GitHub
- HAReact Compiler 1.0 Broke My Forms: Fixing React Hook Form with 'use no memo' (and When to Wait for RHF v8) | Hamza Shabbir
- REIncremental Adoption – React
- RECompiling Libraries – React




