EdgeCases Logo
Mar 2026
React
Deep
7 min read

React Compiler Memoization Boundaries

React Compiler auto-memoizes most code—but effects, refs, and external stores still need manual attention.

react
react-compiler
memoization
performance
usememo
usecallback

React Compiler (formerly React Forget) promises to eliminate manual useMemo, useCallback, and React.memo. It analyzes your code at build time and automatically inserts memoization where beneficial. But it's not magic—there are boundaries where the compiler can't help.

How the Compiler Decides

React Compiler works by analyzing purity. If a value's computation is pure—same inputs always produce same outputs, no side effects—the compiler can safely cache it.

// ✅ Compiler handles this automatically
function ProductCard({ price, quantity }) {
  const total = price * quantity;  // Pure computation → auto-memoized
  const formatted = formatCurrency(total);  // Pure → auto-memoized
  
  return <div>{formatted}</div>;
}

The compiler tracks dependencies and only recomputes when inputs change—exactly what you'd do manually with useMemo.

Where the Compiler Can't Help

1. Effects and Their Dependencies

Effects are inherently impure—they're designed to have side effects. The compiler doesn't touch them:

function DataFetcher({ userId }) {
  const [data, setData] = useState(null);
  
  // ❌ Compiler doesn't optimize effects
  useEffect(() => {
    fetchUser(userId).then(setData);
  }, [userId]);  // Still need manual dep array
  
  return <div>{data?.name}</div>;
}

Effect cleanup, dependency arrays, and execution timing remain your responsibility.

2. Refs and Mutable Objects

Refs bypass React's reactivity—the compiler can't track mutations:

function Canvas({ onDraw }) {
  const canvasRef = useRef(null);
  const contextRef = useRef(null);
  
  // ❌ Ref mutations are invisible to the compiler
  useEffect(() => {
    contextRef.current = canvasRef.current.getContext('2d');
  }, []);
  
  // ❌ Callback using ref—can't be auto-memoized safely
  const draw = () => {
    contextRef.current?.beginPath();  // Mutable ref access
    onDraw(contextRef.current);
  };
  
  return <canvas ref={canvasRef} onClick={draw} />;
}

Any function that reads or writes to refs is excluded from automatic memoization.

3. External Stores and Global State

State outside React's control (Redux, Zustand, global variables) can't be tracked:

import { useStore } from './store';

function Counter() {
  const count = useStore(state => state.count);
  
  // ❌ External store selector—compiler sees it as impure
  const doubled = count * 2;  // Might not be memoized!
  
  return <div>{doubled}</div>;
}

The compiler doesn't know when external stores change, so it's conservative about memoizing derived values.

4. Functions Passed from Non-Compiled Code

If a parent component isn't compiled, its callbacks aren't stable:

// Parent NOT using React Compiler
function LegacyParent() {
  // This creates a new function every render
  return <CompiledChild onClick={() => console.log('clicked')} />;
}

// Child IS compiled, but can't optimize around unstable prop
function CompiledChild({ onClick }) {
  // onClick changes every render → downstream memoization breaks
  const handler = () => {
    onClick();
    trackAnalytics();
  };
  
  return <button onClick={handler}>Click</button>;
}

Gradual adoption creates boundaries where optimization stops.

Intentionally Opting Out

Sometimes you don't want memoization—like random values or timestamps:

function RandomGreeting() {
  // This SHOULD be different every render
  const greeting = greetings[Math.floor(Math.random() * greetings.length)];
  
  return <h1>{greeting}</h1>;
}

Use the "use no memo" directive to exclude specific functions:

function RandomGreeting() {
  "use no memo";  // Opt out of compiler optimization
  
  const greeting = greetings[Math.floor(Math.random() * greetings.length)];
  return <h1>{greeting}</h1>;
}

Or extract volatile logic to a separate function:

// Volatile computation isolated
function getRandomGreeting() {
  "use no memo";
  return greetings[Math.floor(Math.random() * greetings.length)];
}

// Component can be optimized normally
function RandomGreeting() {
  const greeting = getRandomGreeting();
  return <h1>{greeting}</h1>;
}

Debugging Compiler Decisions

React DevTools shows what the compiler optimized:

  • Components with a ✨ badge have compiler optimizations
  • Hover to see which values are memoized
  • Check "Highlight updates" to verify renders are skipped

The compiler also emits build-time warnings when it can't optimize:

// Build output shows what was skipped
⚠️ Cannot memoize: function reads mutable ref
⚠️ Cannot memoize: function has side effects

Migration Strategy

Keep Manual Memoization Where Needed

Don't remove all useMemo/useCallback immediately:

function DataGrid({ rows, columns, onCellClick }) {
  // Keep this if onCellClick comes from non-compiled parent
  const stableHandler = useCallback(
    (cell) => onCellClick(cell),
    [onCellClick]
  );
  
  // Compiler handles the rest
  const processedRows = rows.map(transform);
  
  return <Grid data={processedRows} onClick={stableHandler} />;
}

Audit External Dependencies

Check if your state management library plays well with the compiler. Most modern libraries (Zustand, Jotai) work fine; older patterns might need adjustment.

The Takeaway

React Compiler handles ~80% of memoization automatically. The remaining 20%—effects, refs, external stores, gradual adoption boundaries—still needs manual attention. Don't assume "compiler handles everything" and remove all performance work. Audit the boundaries, use DevTools to verify, and keep manual optimization where the compiler explicitly can't help.

Advertisement

Related Insights

Explore related edge cases and patterns

CSS
Surface
CSS color-mix() for Dynamic Theming
6 min
TypeScript
Expert
TypeScript const Type Parameters
7 min
Next.js
Deep
Next.js 16: The "use cache" Directive and cacheLife
6 min

Advertisement