React Compiler ships with React 19 and automates the memoization that useMemouseCallback, 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. 

Key Takeaways
  • React Compiler memoizes based on strict reference equality (!==), not deep equality. Libraries that mutate a stable object’s internals, like React Hook Form’s useForm() 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(), plain register() → <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.

React Compiler transformation pipeline Five stages: Source (JSX and hooks), Babel AST, High-Level IR, SSA form and mutability analysis, and Cache injection producing compiled output. A dashed marker between stage 1 and 2 shows where the “use no memo” directive halts the pipeline, leaving the AST untransformed. Source Babel AST HIR SSA form + mutability Cache injection JSX + hooks directive check control-flow graph (_c / useMemoCache) compiled output “use no memo” exits here AST returned unmodified; stages 2-5 are skipped for this scope
The compiler’s transformation pipeline. “use no memo” is checked during AST traversal and, if present, skips every later stage for that function.

The compiler works from three invariants, all restated from React’s own rules, not new requirements it invented:

React Rule InvariantCompiler ExpectationLegacy Pattern Violation
Component and hook purityIdentical props/state yield identical output with no render side effectsReading non-reactive external values, or mutating state, during render
Return value immutabilityA hook’s return value only changes by returning a new referenceMutating properties inside a stable, long-lived container object
Ref isolationref.current is never read or written during renderReading 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:

  1. Stale reads from a mutable container. form.watch("email") gets compiled into the equivalent of useMemo(() => form.watch("email"), [form]). Because form never changes reference, the memoized value is computed once and never again. React’s own incompatible-library ESLint rule documentation uses useForm().watch() as its canonical example of this exact pattern (react.dev: incompatible-library), and independently corroborates the mechanism described here.
  2. Bypassed Proxy getters. formState uses Object.defineProperty getters that register a field subscription as a side effect of being read. Destructure formState.errors inside 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.
  3. Ref reads during render. RHF’s internals read ref.current in 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 / APIBreaks under React Compiler?Why
React Hook Form watch()formState, plain register() + reset()YesInterior mutability, Proxy-getter bypass, ref reads
TanStack Table useReactTable()YesReturns a mutable table instance with a stable reference
TanStack Virtual useVirtualizer()YesSame interior-mutability pattern
MobX observer()YesObservable-mutation model conflicts with reference-based caching
Zustand, Jotai, TanStack QueryNoBuilt 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.

JavaScript
// Function-level opt-out
function LegacyFormHandler() {
  "use no memo";
  const form = useForm();
  return <form>{/* runs without compiler optimization */}</form>;
}
JavaScript
// 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 compilationMode setting, 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.

TypeScript
// 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():

TypeScript
// 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():

TypeScript
// 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():

TypeScript
// 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 PatternCompiler-Resilient AlternativeWhy It Works
const val = watch('field')const val = useWatch({ name: 'field', control })Explicit hook subscription replaces a snapshot container read
const { isDirty } = formStateconst { 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 renderMove to event handlers, or use useWatchKeeps render pure; snapshot reads belong in callbacks
table.getHeaderGroups() rendered directlyPass extracted plain-array props to childrenBypasses 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.

TypeScript
// 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:

React Hook Form compiler-compatibility test pass rate by stack Community regression suite (timkindberg/rhf-compiler-compat) results: React 18 plus release-candidate compiler passed 14 of 27 tests (52%). React 19 plus compiler 1.0.0 GA plus RHF 7.x passed 23 of 28 tests (82%). RHF 8.0.0-beta.2 passed 28 of 28 tests (100%). React 18 + RC compiler + RHF 7.x 52% 14 / 27 tests passing React 19 + Compiler 1.0 GA + RHF 7.x 82% 23 / 28 tests passing RHF 8.0.0-beta.2 (React Compiler ready) 100% 28 / 28 tests passing Source: github.com/timkindberg/rhf-compiler-compat regression suite (2026)
Source: timkindberg/rhf-compiler-compat regression suite (2026).

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:

JavaScript
// babel.config.js
module.exports = {
  plugins: [
    [
      'babel-plugin-react-compiler',
      {
        compilationMode: 'infer',
        target: '19',
        gating: {
          source: 'src/config/featureFlags',
          importSpecifierName: 'isCompilerEnabled',
        },
        panicThreshold: 'none',
      },
    ],
  ],
};
OptionValuesPurpose
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:

JavaScript
// 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):

JSON
{
  "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

  1. Tooling baseline and audit. Set panicThreshold: 'none' so uncompilable modules degrade gracefully instead of breaking the build. Install eslint-plugin-react-hooks across the whole repo to surface existing Rules-of-React violations before you touch a single component. Use overrides or gating to scope compilation to modern feature directories first.
  2. Granular legacy isolation. For existing form-heavy screens, wrap useForm() (and equivalent calls in other libraries) inside a compatibility hook like useFormCompat. This confines "use no memo" to the hook frame and avoids directive sprawl across the component tree.
  3. Standardize resilient authoring for new work. Stop writing watch(), raw formState destructuring, and plain register() bindings in any new code. Default to useWatch()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