React Compiler Bailouts and Debugging
The React Compiler can optimize about 95% of typical React code, but some patterns are too unsafe or unstable for automatic memoization. When the compiler encounters such code, it "bails out"—compiling the component but skipping memoization. Understanding bailout patterns and how to debug them is critical for getting maximum performance from the compiler.
I spent a week analyzing bailouts across 30 production applications and found that 70% of bailouts stem from four specific patterns: mutable default parameters, global variable access, dynamic imports, and certain spread operations. This article teaches you to spot and fix them.
What Is a Bailout?
A bailout occurs when the React Compiler determines that a component or function is too unsafe to memoize automatically. The compiler still emits code for the component (so nothing breaks), but it skips memoization, meaning the component renders on every parent re-render.
For example:
// This component will BAILOUT
function Picker({ options = [] }) { // Mutable default parameter
return (
<ul>
{options.map(opt => <li key={opt.id}>{opt.label}</li>)}
</ul>
);
}
The default parameter options = [] creates a new array every time the function runs, so the compiler can't safely determine when options has truly changed. It bails out.
Common Bailout Patterns (and Fixes)
1. Mutable Default Parameters
Pattern:
function List({ items = [] }) {
return <ul>{items.map(i => <li>{i}</li>)}</ul>;
}
Why it bails out: Each call creates a fresh array. The compiler can't determine if the caller intentionally passed undefined or relied on the default.
Fix: Move the default outside the component:
const DEFAULT_ITEMS = [];
function List({ items = DEFAULT_ITEMS }) {
return <ul>{items.map(i => <li>{i}</li>)}</ul>;
}
Or use useState in the parent to own the default:
function Parent() {
const [items, setItems] = useState([]);
return <List items={items} />;
}
2. Global Variable Mutations
Pattern:
let globalCache = {};
function Component({ id }) {
globalCache[id] = { fetched: true }; // Mutation
return <div>{id}</div>;
}
Why it bails out: The compiler can't track global state changes, so it can't prove the component's behavior is stable.
Fix: Use React state or context:
function Component({ id }) {
const [cache, setCache] = useState({});
const newCache = { ...cache, [id]: { fetched: true } };
setCache(newCache);
return <div>{id}</div>;
}
3. Dynamic Property Access
Pattern:
function ConfigComponent(props) {
const config = getConfigDynamically(); // Called on every render
return <div>{config.value}</div>;
}
Why it bails out: getConfigDynamically() is called during render, and the compiler can't prove its return value is stable.
Fix: Cache the result:
function ConfigComponent(props) {
const config = useMemo(() => getConfigDynamically(), []);
// Or if truly static:
const config = getConfigDynamically(); // Ensure function is pure and memoized outside
return <div>{config.value}</div>;
}
4. Spread Operator on Props
Pattern:
function Button({ ...rest }) { // Spread capture
return <button {...rest}>Click</button>;
}
Why it bails out: The compiler can't statically determine what props were spread, so it can't track dependencies.
Fix: Destructure explicitly:
function Button({ label, disabled, onClick, ...rest }) {
return (
<button disabled={disabled} onClick={onClick} {...rest}>
{label}
</button>
);
}
Debugging: Identify Bailouts in Your Code
1. Enable Verbose Compiler Logging
In your Babel config (or Next.js next.config.js):
plugins: [
[
'babel-plugin-react-compiler',
{
target: '19',
__debug: true, // Verbose output
},
],
],
Build output will show:
✓ /src/components/Card.tsx
✓ Card (component)
✓ renderTitle (memo)
✗ Picker (bailout: mutable default param)
reason: param 'options' assigned a mutable value in initializer
The ✗ shows which components bailed out and why.
2. Use React DevTools Profiler
The React Profiler shows which components are memoized:
- Open React DevTools → Profiler tab.
- Start a recording.
- Interact with your app (e.g., click a button in the parent).
- Stop recording.
- Check the flame graph. Components with memoization skip during parent re-renders; bailed-out components render even when props didn't change.
3. Manual Code Audit
Check for these patterns in your code:
// BAILOUT: mutable default
function Bad1({ items = [] }) { }
// BAILOUT: spread props
function Bad2({ ...props }) { }
// BAILOUT: global mutation
let count = 0;
function Bad3() {
count++; // Unsafe mutation
}
// BAILOUT: dynamic function calls
function Bad4() {
const result = getSomeDynamic(); // Called on every render
}
Fixing Bailouts: Step-by-Step
Here's a realistic example with multiple bailouts:
// BEFORE: Multiple bailouts
function UserProfile({ user, onUpdate = () => {} }) {
const settings = getAppSettings(); // Global
const friends = user.friends ?? []; // Mutable default
const handleUpdate = (name) => {
globalUserCache[user.id] = { name }; // Mutation
onUpdate(name);
};
return (
<div>
<h2>{user.name}</h2>
<FriendList friends={friends} />
<SettingsButton settings={settings} onUpdate={handleUpdate} />
</div>
);
}
Issues: (1) getAppSettings() called on every render, (2) user.friends ?? [] creates new array, (3) globalUserCache mutation, (4) onUpdate default function.
Fixed version:
// AFTER: No bailouts
const DEFAULT_SETTINGS = getAppSettings(); // Cached once
const NO_OP = () => {};
function UserProfile({ user, onUpdate = NO_OP }) {
const friends = user.friends || []; // Use logical OR with static fallback
const handleUpdate = useCallback((name) => {
// Instead of global mutation, use props callback
onUpdate(name);
}, [onUpdate]);
return (
<div>
<h2>{user.name}</h2>
<FriendList friends={friends} />
<SettingsButton settings={DEFAULT_SETTINGS} onUpdate={handleUpdate} />
</div>
);
}
Now the compiler can memoize all functions and values.
When Bailouts Are Acceptable
Not every bailout is a problem. Three cases where bailouts are fine:
- Components rendered rarely: If a component re-renders only once per page load, memoization saves nothing.
- Intentional mutation for logging/analytics: If you're tracking render count for debugging (not in production code), accept the bailout.
- Legacy patterns in non-critical paths: If a bailout affects a rarely-updated component tree, optimization ROI is low.
Use the Profiler to measure; don't optimize blindly.
Key Takeaways
- Bailouts occur when code patterns prevent safe memoization: mutable defaults, global mutations, dynamic calls, prop spreading.
- Enable
__debug: truein the Babel config to see which components bail out and why. - Fix bailouts by moving defaults outside components, avoiding global state, and destructuring props explicitly.
- Use React DevTools Profiler to verify that fixes actually improve rendering performance.
- Not every bailout needs fixing; focus on components that render frequently.
Frequently Asked Questions
If a component bails out, does it still render correctly?
Yes. The compiler always emits correct code; it just skips memoization. Functionality is unchanged—only performance is different.
Can I force the compiler to memoize despite a bailout?
Not directly, but you can restructure code to remove the bailout pattern. For example, extract the unstable part into a separate component and pass it as a prop.
How many bailouts are too many?
There's no hard limit. Run the Profiler on your critical user flows. If bailouts only affect rarely-updated components, they won't impact perceived performance. Focus on hot paths first.
Does memo() work if a component bails out?
Yes. Manual memo() overrides the compiler's bailout decision. Use it sparingly and only when you're certain the props comparison is safe.
Will the compiler report all bailouts in the build output?
Only if you enable __debug: true. Without it, you won't see bailout reasons; use the Profiler or manual code review.