React Compiler limitations hide in the components it silently skips

Picture the rollout. A team sets reactCompiler: true in next.config.ts, the build passes, and a pull request deletes every useMemo and useCallback in the repository. Two weeks later a field in a react-hook-form form stops showing what the user types, and an analytics effect fires on every render. Nothing in the build output pointed at either one.
React Compiler limitations are quiet ones. React Compiler is a build-time tool that adds memoization to components and hooks automatically, stable since version 1.0 on 7 October 2025. When it meets code it cannot prove safe, it skips that code and keeps building, and the default settings report nothing. So the real limits sit in two places: components the compiler never optimized, and components it optimized that relied on memoization to behave correctly.
What the mistake looks like in a real codebase
The rollout above rests on three assumptions. Each one is false in a way the React docs describe.
- Every component is now compiled. In the default
infermode the compiler picks only functions named like components (PascalCase) or hooks (useprefix) that create JSX or call hooks. AndpanicThresholddefaults tonone: any function it cannot compile is skipped and the build continues. - Compiled code behaves like the old code. React's debugging guide names code "written to rely on memoization for correctness" as one of the main ways the compiler can break an app.
- Manual memoization is dead weight. The React docs recommend leaving existing
useMemoanduseCallbackin place, because removing them can change what the compiler outputs.
We covered when hand-written memoization still pays in our earlier piece on the compiler. This one is about the code the compiler does not reach, and how to see it.
Where does the React Compiler skip a component?
The compiler is built to handle code that follows the Rules of React. When a component might break them, the docs say it "safely skips optimization rather than risk changing your app's behavior." The component still runs. It runs unmemoized, exactly as it did before you turned the compiler on. Four patterns cause most of the skips.
Refs read or written during render
Reading ref.current in the render body, or assigning to it, breaks the rule that render stays pure. The refs lint flags both. The compiler infers a ref from useRef(), from a JSX ref prop, or from any name ending in Ref that touches .current. One exception is allowed: lazy initialization, where you fill ref.current once when it is still null. The fix for the rest is to move the read into an effect or an event handler, or to turn the value into state if the UI depends on it.
Libraries with interior mutability
Some libraries return an object or a function whose reference stays the same while its contents change. Memoization checks the reference, so the value looks frozen. The incompatible-library lint names two examples: react-hook-form's watch and TanStack Table's useReactTable. The compiler skips components that call APIs on its known list. MobX's observer breaks the same assumption, and the docs say the linter does not detect it yet, so a MobX codebase can be compiled and misbehave. For react-hook-form the fix is useWatch, which returns a value that changes reference when the field changes.
Functions that do not look like React
A helper called calculateTotal is not compiled in infer mode, whatever it costs to run. Nor is a component written with a lowercase name. Even when a heavy calculation sits inside a component, the introduction is explicit: the compiler only memoizes components and hooks, and its memoization is not shared across them. The same expensive call in five components runs five times.
Side effects during render
A render body that writes to the DOM, mutates a value it received, or calls something non-deterministic is outside what the compiler can treat as pure. The purity and immutability rules in eslint-plugin-react-hooks report these. They were latent bugs before the compiler arrived. The compiler makes them visible, or skips around them.
Where does compiled code behave differently?
Skipping is the safe failure. The unsafe one is a component the compiler did compile, built on an assumption the compiler does not share. React's troubleshooting page lists three shapes:
- Effects that rely on referential equality. An effect depends on an object or array and expects the same reference across renders.
- Dependency arrays that need stable references. An unstable dependency makes an effect fire too often, or loop.
- Logic that branches on a reference check. Code that compares
prev === nextto decide whether to recompute or refetch.
The symptoms are effects over-firing, infinite loops and updates that never arrive. The docs give a clean test. Remove all manual memoization from the component and run it without the compiler. If the bug is still there, the component breaks a Rule of React and the compiler only exposed it. If the bug disappears, the code depended on memoization to be correct, and that dependency is what to remove.
Next.js adds one setting worth knowing. enablePreserveExistingMemoizationGuarantees controls whether the compiler keeps the guarantees of your existing useMemo, useCallback and React.memo. The Next.js docs warn that its default can differ between compiler implementations and versions, and advise setting it explicitly. A version bump that flips it changes behavior in exactly the components described above.
What the compiler leaves to you by design
Some limits are choices, written into the docs.
- It targets re-renders. The compiler's memoization is aimed at update performance: skipping cascading re-renders and repeated calculations. A slow first load caused by bundle size, images or data fetching is a different problem, the one we take apart in why a website is slow.
- Its gains are measured on someone else's app. On the Meta Quest Store the React team reports initial loads and cross-page navigations up to 12% faster and some interactions more than 2.5 times faster, with neutral memory. The same post says "your mileage may vary". Measure your own screens.
- It costs build time. The compiler runs as a Babel plugin. Next.js filters with SWC and applies it only to files with JSX or hooks, and still expects slightly slower builds than its default Rust compiler.
- Server Components gain little from it. A Server Component renders once per request and never re-renders in the browser, so the memoization the compiler adds is aimed at your client components. Our notes on Server Components in production cover where that line falls.
How to find the skipped components in an existing codebase
Coverage is the number to know, and the default build does not print it. Five steps give you a reading.
- Turn on the lint first. Since 1.0 the compiler's rules ship in
eslint-plugin-react-hooks, in therecommendedpreset. The linter does not need the compiler installed, so you can run it on a codebase before enabling anything. Every report is a component the compiler will skip or a bug it will expose. - Log what the compiler does. Where the build runs the Babel plugin directly (Vite, Metro, a plain Babel config), add a
logger. It emitsCompileSuccess,CompileErrorandCompileSkipper function. Count them per directory and you have a coverage report. - Check the screens that matter in DevTools. React DevTools marks a compiled component with a Memo ✨ badge. Open the five screens your users spend most time on and look for components without it.
- Isolate a runtime bug with
"use no memo". The directive skips one function, or a whole file when placed at the top. The docs call it a temporary debugging tool, so pair each one with a comment that says why and links the issue. - Replace incompatible APIs one by one.
watchbecomesuseWatch. A ref read in render moves into an effect. Each fix brings a component back under the compiler.
How to keep it from coming back
Prevention is mostly configuration, set once.
- Lint as an error in CI. A pull request that adds a ref read in render fails before review.
- A stricter threshold in development only.
panicThreshold: 'critical_errors'during local work surfaces compile failures. Production stays onnone, as the docs require, so a compiler issue never blocks a deploy. - Roll out by directory. The incremental adoption guide offers three routes: Babel overrides per folder,
compilationMode: 'annotation'with an explicit"use memo", or a runtime gate behind a feature flag for an A/B comparison. - Keep manual memoization where an effect depends on it. For new code we let the compiler decide and add
useMemoonly where a value feeds an effect. Existing memoization stays until a test proves it can go, and thepreserve-manual-memoizationlint checks that its dependencies are complete. - Pin the Next.js memoization setting. Write
enablePreserveExistingMemoizationGuaranteesinto the config with the value you tested, so an upgrade cannot change it for you.
The compiler earns its place on most React codebases. Its limits are the components it never touched and the ones that only worked because something was memoized. Both are findable in an afternoon with the lint, a logger and DevTools, and both stay invisible to a team that only reads the build log.
Sources
- React Compiler v1.0, React blog
- React Compiler introduction, react.dev
- React Compiler debugging and troubleshooting, react.dev
- React Compiler incremental adoption, react.dev
- compilationMode reference, react.dev
- panicThreshold reference, react.dev
- logger reference, react.dev
- "use no memo" directive, react.dev
- incompatible-library lint, react.dev
- refs lint, react.dev
- preserve-manual-memoization lint, react.dev
- reactCompiler option, Next.js docs
Frequently asked questions
Not in existing code, at least not in bulk. The React docs recommend leaving existing memoization in place because removing it can change what the compiler outputs, or testing carefully before each removal. In new code, write plain values and functions and let the compiler memoize them. Keep useMemo or useCallback where a value is an effect dependency and must keep the same reference, since that is the one place the docs still name as a reason for manual control.
Somewhat. The compiler runs as a Babel plugin, and Next.js normally compiles with SWC, its Rust-based compiler. To limit the cost, Next.js uses an SWC pass to apply the React Compiler only to files with JSX or hooks. The Next.js docs still say to expect slightly slower builds than the default, with the impact small and localized. Measure your own build time before and after on CI rather than relying on a general figure.
Yes. The React team states the compiler is compatible with React 17 and up. On React 17 or 18 you set a minimum target in the compiler configuration and add the react-compiler-runtime package as a dependency. On React 19 neither step is needed. The same skip rules apply on every version, so the lint and coverage checks in this article are just as useful on an older codebase.
Related articles
September 9, 2026 · 7 min read
April 23, 2026 · 7 min read
April 18, 2026 · 7 min read