When React Compiler Quietly Gives Up on Optimization — Bailout Causes and Detection Strategies (React Compiler v1.0)
When React Compiler v1.0 officially stabilized in October 2025, my team started adopting it. We were hopeful — "finally free from the manual useMemo hell." But once we actually hooked it up in production, we found that the compiler was silently skipping optimization for certain components entirely, far more often than we expected. At first we shrugged it off — "it just falls back to how things were before, right?" — but then useEffect started firing at unexpected moments (a bug that hadn't existed before the compiler was introduced), and we realized this was more than just a performance hit.
This phenomenon is called a Bailout. It's when the compiler, during static analysis, decides "this component cannot be safely optimized" and abandons the optimization entirely. The core issue is that it happens silently, with no warning by default. If you're tracking down React Compiler performance problems or optimization failures, building a system to detect bailouts is just as important as turning the compiler on in the first place.
Core Concepts
Why React Compiler Skips Optimization
React Compiler statically analyzes components and hooks at build time and automatically inserts useMemo, useCallback, and memo. For this analysis to hold, three assumptions must be met — no side effects during render, render functions must be pure, and props and state must be treated as immutable. These are called the "Rules of React."
When the compiler can't verify statically that these assumptions hold, it falls back to a "when in doubt, don't touch it" principle and skips optimization for that component or hook entirely.
What is a Bailout? A situation where the compiler, unable to guarantee the safety of an optimization during static analysis, completely skips optimizing a component or hook. It falls back to standard React behavior, but by default happens silently, with no warning.
// Ideal case the compiler optimizes
function GoodComponent({ items }) {
const sorted = [...items].sort(); // returns a new array → safe
return <List data={sorted} />;
}
// Case where the compiler bails out
function BadComponent({ items }) {
items.sort(); // mutates the original array → violates Rules of React → bailout
return <List data={items} />;
}Three Types of Bailout
| Type | When It Occurs | Representative Cases |
|---|---|---|
CannotPreserveMemoization |
When the compiler cannot statically guarantee the safety of the memoization it wants to insert | Complex dependency chains, conflicts with existing useMemo |
UnsupportedSyntax |
When it encounters syntax whose control flow cannot be traced | Complex conditional branches inside try/catch |
IncompatibleLibrary |
When using an external library that conflicts with the compiler's immutable reference assumption | MobX observer()¹, React Hook Form watch() |
¹
observer()is MobX's reactive wrapper function that detects mutations to observable objects and triggers component re-renders. This "direct mutation" model conflicts with the compiler's immutability assumption.
There's one misconception worth addressing. It's easy to assume CannotPreserveMemoization only occurs "when you're already using useMemo directly," but in practice it's a much broader concept. It occurs across any situation where the compiler cannot statically verify the correctness of the memoization it wants to insert — conflicts with existing useMemo are just one sub-case. If you conclude "I don't use useMemo, so I won't hit this bailout," you'll miss cases.
What Problems Does Silent Failure Actually Cause?
I also thought at first, "it just falls back to the unoptimized state, right?" But it's not that simple, for two reasons.
First, if you've already removed your existing useMemo/useCallback in trust of the compiler, and then a bailout occurs, performance actually regresses compared to before. Second, and more seriously, there are correctness bugs. If the compiler restructures memoization differently than before, useEffect hooks that depended on referential equality may fire at unexpected times.
What is Referential Equality? In JavaScript, objects and arrays evaluate as
!==even if their contents are identical, as long as they are different references. SinceuseEffectdependency arrays are compared by referential equality, if the compiler changes the memoization structure, an effect may re-run even for "the same value."
Practical Application — Bailout Patterns
Example 1: Direct Mutation of Props/State — The Most Common Bailout
This is the situation you'll encounter most often in practice. When implementing forms or edit UIs, patterns that inadvertently mutate props objects directly tend to linger.
// ❌ bailout: directly mutating the props object
function BadForm({ user }) {
user.name = 'edited'; // directly mutates props → violates Rules of React → bailout
return <input value={user.name} />;
}
// ✅ manage local edit state separately with useState
function GoodForm({ user }) {
const [name, setName] = useState(user.name);
return <input value={name} onChange={e => setName(e.target.value)} />;
}| Point | Detail |
|---|---|
| Cause | user.name = ... directly mutates the props object |
| Compiler response | Bails out on the entire component |
| Solution | Manage the edited value as local state with useState |
Example 2: Reading ref.current During Render
useRef can change independently of the render cycle, so the compiler has no way to track its dependencies. The key point in this case is the situation where a ref is received as a prop and read during render. The fix isn't to internalize ref.current — it's to change the value passed in from outside to a regular prop or state.
// ❌ bailout: reading a ref received as a prop during render
function Counter({ countRef }) {
const doubled = countRef.current * 2; // dependency cannot be tracked → bailout
return <div>{doubled}</div>;
}
// ✅ pass a regular value as a prop instead of a ref
function Counter({ count }) {
const doubled = count * 2;
return <div>{doubled}</div>;
}The general direction is to restructure so that ref access occurs inside event handlers or useEffect.
Example 3: Calling setState During Render — Including Inside try/catch
This is a case where the bailout causes need to be distinguished separately. I initially lumped it together as "the try/catch is the problem," but in reality these are two separate violations.
// ❌ bailout cause A: calling setState directly inside the render function
function DataFetcher({ cache }) {
let data = null;
try {
data = cache.get();
} catch (e) {
setError(e); // state mutation during render → Rules violation
}
return <View data={data} />;
}
// ❌ bailout cause B: complex conditional branching inside try/catch
function DataFetcher({ cache }) {
let data = null;
try {
if (!cache.isValid() && cache.hasBackup()) {
// complex branching → compiler gives up on control flow tracing
data = cache.getBackup();
} else if (cache.isPending()) {
data = cache.lastKnown;
}
} catch (e) {
data = null;
}
return <View data={data} />;
}Cause A is a purity violation in the render function itself; cause B is abandoned static analysis due to branching complexity. When both violations appear in the same component simultaneously, it's hard to isolate which one triggered the bailout — so it's recommended to fix them one at a time and verify.
Warning: try/catch silent bailout bug (discovered January 2026) There is a currently-open bug (
facebook/react #35644) where the presence of even a singletry/catch/finallyblock in a component body disables all ESLint error reporting for that component. This means there are gaps where ESLint cannot be trusted, so it's practical to either manually audit components containingtry/catchor temporarily exclude them with'use no memo'.
Example 4: MobX observer() — Structural Incompatibility
MobX implements reactivity by directly mutating observable objects. This is a direct conflict with React Compiler's "immutable references" assumption. I actually dug into this issue together with a team using MobX, and the MobX maintainers themselves acknowledged it as a "fundamentally conflicting model" (official GitHub issue #3874) — there was no choice but to accept that there's no short-term fix.
Components wrapped with observer() will have their entire optimization skipped by the compiler. The realistic options are two:
- Explicitly opt-out with
'use no memo'(short-term) - Gradually migrate to Zustand, which operates on a
useSyncExternalStore² basis (long-term)
²
useSyncExternalStoreis an external store subscription hook added in React 18. Because it reads state via immutable snapshots, it is fully compatible with the compiler.
Example 5: React Hook Form watch() — incompatible-library Detection
The watch() API internally uses a mutable subscription approach. This triggers an incompatible-library³ lint warning and leads to a bailout.
// ❌ compiler detects as incompatible-library
const { watch } = useForm();
const value = watch('fieldName'); // internal mutable subscription → bailout
// ✅ switch to the Controller pattern
<Controller
name="fieldName"
control={control}
render={({ field }) => <Input {...field} />}
/>³
incompatible-libraryis a lint rule provided byeslint-plugin-react-compilerthat detects the use of external libraries structurally incompatible with the compiler.
Example 6: 'use no memo' — How to Use This Temporary Escape Hatch
This directive is useful when narrowing down a bug to determine "is this a compiler problem or a code problem?"
function DebugComponent() {
'use no memo'; // completely excludes this component from compiler optimization
return <ComplexView />;
}
'use no memo'is a temporary escape hatch. Unlike"use client", it's not a permanent declaration — it's a debugging tool that should be removed once the root cause is confirmed. If you see this directive in a code review, it's worth checking whether the PR description includes a plan to remove it.
Bailout Detection Strategies
Strengthening ESLint Configuration
The first layer of detection is ESLint. Install eslint-plugin-react-compiler and elevate rules from their default warn to error to enable build blocking. The configuration below is recommended as of the verification date (October 2025); since rule names may differ by plugin version, it's recommended to check the latest rule names in the official ESLint plugin documentation.
{
"plugins": ["react-compiler"],
"rules": {
"react-compiler/react-compiler": "error"
}
}To surface hidden bailouts, you can add the __unstable_donotuse_reportAllBailouts: true option.
Warning: The name
__unstable_donotuse_reportAllBailoutsitself signals that this is an unstable API. It's recommended to enable it only in development environments and not include it in production builds.
Detecting Bailout Regressions in CI
ESLint alone can't catch every bailout. By using the logger option in babel-plugin-react-compiler, you can track CompileSkip events in your CI pipeline.
// babel.config.js (for Babel-based projects)
const BabelPluginReactCompiler = require('babel-plugin-react-compiler');
module.exports = {
plugins: [[BabelPluginReactCompiler, {
logger: {
logEvent(filename, event) {
if (event.kind === 'CompileSkip') {
process.stderr.write(`BAILOUT: ${filename} — ${event.fnName}\n`);
}
}
}
}]]
};For Vite-based projects, you can apply an equivalent configuration in @vitejs/plugin-react v6.
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react({
babel: {
plugins: [['babel-plugin-react-compiler', {
logger: {
logEvent(filename, event) {
if (event.kind === 'CompileSkip') {
process.stderr.write(`BAILOUT: ${filename} — ${event.fnName}\n`);
}
}
}
}]]
}
})]
});One thing to be aware of: event.kind === 'CompileSkip' is an internal API value. If this value changes on a version upgrade, CI detection may silently stop working — so when upgrading the compiler version, verify that log output is still functioning.
Pros and Cons Analysis
Honestly, looking at a pros and cons table, the cons column can feel long enough to make you think "maybe I'm better off not using this." But context matters. The real question when adopting the compiler isn't "are there zero bailouts?" but rather "do I have a system to detect and manage bailouts?"
Pros
| Item | Detail |
|---|---|
| Reduced manual work | No need to write useMemo/useCallback/memo by hand |
| Incremental adoption | Can opt-out per component, enabling safe introduction into existing codebases |
| Prevents missed optimizations | The compiler automatically covers optimization points that humans miss |
| Tooling ecosystem | Detection tools like the ESLint plugin and logger option are maturing quickly |
Cons and Caveats
| Item | Detail | Mitigation |
|---|---|---|
| Silent Bailout | Optimization failures occur with no warning by default | logger option + __unstable_donotuse_reportAllBailouts (dev environment only) |
| ESLint dependency | eslint-plugin-react-compiler must be configured for detection |
Elevate rules to error to block builds |
| try/catch bug | If a try/catch block is present, ESLint detection for the component is disabled entirely |
Manual audit of affected components or 'use no memo' |
| Library compatibility | Many libraries including MobX and React Hook Form are incompatible | Migrate to compatible alternatives or explicitly opt-out |
| Correctness bugs | useEffect hooks that relied on referential equality may fire unexpectedly |
Opt-out with 'use no memo' and fix the root cause |
| Dynamic property access | The obj[dynamicKey] pattern bails out due to inability to statically analyze |
Change to static key access where possible |
Most Common Mistakes in Practice
-
Leaving ESLint warnings at
warn— If the default iswarn, builds pass and bailouts accumulate silently. Elevating toerrorto catch them at the PR stage is effective. -
Using
'use no memo'as a permanent fix instead of a temporary one — If you start attaching the directive without root-cause analysis, the benefits of adopting the compiler gradually erode. Once it's attached, it's recommended to keep a ticket open until the root cause is found. -
Enabling globally without checking library compatibility — Components using incompatible libraries like MobX or certain TanStack Query APIs will silently bailout in large numbers if you turn on the compiler without a prior audit. The safe order is: first collect
incompatible-librarywarnings with ESLint, determine migration priorities, then apply.
# Collect incompatible-library warning list
npx eslint src --rule 'react-compiler/react-compiler: warn' 2>&1 | grep incompatible-libraryClosing Thoughts
To truly benefit from React Compiler, it's just as important to have a system for detecting and tracking bailouts as it is to turn the optimization on.
When I attached the bailout logger to CI and looked at the results, far more components were being CompileSkipped than I expected. It took a while to go through the ESLint configuration and code patterns one by one to bring the count down — but that process turned out to be a good opportunity to look at hidden patterns in the codebase.
Here are 3 steps I recommend starting with right now:
-
Elevate ESLint rules to
error. Addeslint-plugin-react-compilerto your.eslintrcand raise the rule toerrorlevel — new bailout patterns will be caught at the PR stage. -
Attach the bailout logger to CI. Apply the
babel.config.js(orvite.config.js) example above, and you'll be able to see in CI logs which file and function is skipping optimization. Simply tracking whether the bailout count increases in new PRs is enough to catch regressions early. -
Audit your current codebase for compatibility. Use the ESLint command above to collect
incompatible-librarywarnings, check the list of components using incompatible libraries like MobX or React Hook Form'swatch(), and then determine migration priorities.
References
- React Compiler Official Documentation | react.dev
- React Compiler v1.0 Release Notes | react.dev
- 'use no memo' Directive Reference | react.dev
- incompatible-library ESLint Rule | react.dev
- React Compiler Debugging Guide | react.dev
- React Compiler's Silent Failures and How to Fix Them | acusti.ca
__unstable_donotuse_reportAllBailoutsDiscussion | reactwg GitHub- React Hook Form and React Compiler Incompatibility Issue Analysis | zenn.dev
- MobX React Compiler Incompatibility Issue | GitHub #3874
- try/catch silent bailout bug | facebook/react #35644
- React Compiler Configuration Reference | react.dev