Back to Insights
React 19PerformanceCompiler

React 19 Compiler Deep Dive: Automatic Memoization and the End of useMemo

04/202612 min read
Share
React 19 Compiler Deep Dive: Automatic Memoization and the End of useMemo

The End of Manual Memoization

For years, React developers have relied on useMemo, useCallback, and React.memo to prevent unnecessary re-renders. These APIs work, but they add cognitive overhead, clutter codebases, and are easy to misuse. The React Compiler changes everything.

Shipping with React 19, the React Compiler (formerly "React Forget") is an ahead-of-time build tool that automatically memoizes components and hooks at compile time. It understands the rules of React, tracks data dependencies, and inserts fine-grained memoization where it's actually needed — without you writing a single memo wrapper.

  • Automatic memoization at build time — no runtime overhead from useMemo/useCallback
  • Fine-grained dependency tracking — more precise than manual dependency arrays
  • Zero code changes required — works with existing idiomatic React code
  • Eliminates entire categories of performance bugs (stale closures, missing deps)
  • Ships as a Babel plugin for Next.js 16 with first-class integration

#1 How the React Compiler Works

Understanding the compiler's mental model is essential before adopting it. It doesn't just wrap everything in useMemo — it performs static analysis to understand your component's data flow and only memoizes what matters.

Compilation Pipeline

The compiler operates during build time through three phases:

  • Parse: Analyzes your component's source code into an intermediate representation (IR)
  • Analyze: Tracks how every value flows through the component — which variables are read, which are mutated, and which are stable across renders
  • Transform: Inserts memoization boundaries around values that would cause unnecessary re-computation or re-rendering
Before compilation
// What you write — clean, no manual memoization function ProductCard({ product, onAddToCart }) { const discount = product.price * 0.1; const finalPrice = product.price - discount; const formattedPrice = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(finalPrice); return ( <div className="card"> <h3>{product.name}</h3> <span>{formattedPrice}</span> <button onClick={() => onAddToCart(product.id)}> Add to Cart </button> </div> ); }
After compilation (conceptual output)
// What the compiler produces — fine-grained caching function ProductCard({ product, onAddToCart }) { const $ = _c(5); // compiler-managed cache slots let formattedPrice; if ($[0] !== product.price) { const discount = product.price * 0.1; const finalPrice = product.price - discount; formattedPrice = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(finalPrice); $[0] = product.price; $[1] = formattedPrice; } else { formattedPrice = $[1]; } let t0; if ($[2] !== product.name || $[3] !== formattedPrice || $[4] !== onAddToCart) { t0 = ( <div className="card"> <h3>{product.name}</h3> <span>{formattedPrice}</span> <button onClick={() => onAddToCart(product.id)}> Add to Cart </button> </div> ); $[2] = product.name; $[3] = formattedPrice; $[4] = onAddToCart; } return t0; }

Key Insight: Granularity

Notice how the compiler doesn't just memoize the entire component. It creates separate cache slots for the price calculation and the JSX output. This is more granular than what most developers would do manually — and it's why the compiler often outperforms hand-optimized code.

  • Manual useMemo typically wraps large blocks — the compiler memoizes individual expressions
  • The compiler tracks exact dependencies — no risk of stale closures from missing deps
  • It uses a flat cache array (_c) with positional slots — minimal memory overhead
Result
  • More precise memoization than manual optimization
  • Zero developer effort
  • No risk of dependency array mistakes

#2 Enabling the Compiler in Next.js 16

Next.js 16 ships with first-class React Compiler support. Enabling it is a single configuration change, but understanding the options helps you adopt incrementally.

Basic Setup

next.config.ts
import type { NextConfig } from 'next'; const nextConfig: NextConfig = { experimental: { reactCompiler: true, }, }; export default nextConfig;

That's it. The compiler will now analyze and optimize every component and hook in your application during build.

Incremental Adoption with Opt-In Mode

For large codebases, you can enable the compiler in opt-in mode — only components explicitly marked with "use memo" will be compiled:

next.config.ts
const nextConfig: NextConfig = { experimental: { reactCompiler: { compilationMode: 'annotation', }, }, };
components/ExpensiveList.tsx
'use memo'; // Only this file is compiled in annotation mode export function ExpensiveList({ items, filter }) { const filtered = items.filter(item => item.name.includes(filter) ); return ( <ul> {filtered.map(item => ( <li key={item.id}>{item.name}</li> ))} </ul> ); }

Opting Out Specific Components

If a component doesn't follow the rules of React (e.g., mutates during render), you can opt it out with "use no memo":

'use no memo'; // This component is skipped by the compiler export function LegacyChart({ data }) { // ... uses patterns incompatible with compiler }
Result
  • One-line setup in Next.js 16
  • Incremental adoption for large codebases
  • Escape hatch for edge cases

#3 What You Can Remove

The biggest practical benefit of the React Compiler is the code you can delete. Manual memoization primitives are no longer needed in compiled components — and removing them makes your code cleaner and easier to maintain.

Before: Manual Memoization

components/Dashboard.tsx (before)
'use client'; import { memo, useCallback, useMemo } from 'react'; interface DashboardProps { data: DataPoint[]; onExport: (format: string) => void; } // Wrapped in React.memo to prevent re-renders const MetricCard = memo(function MetricCard({ label, value, }: { label: string; value: number; }) { return ( <div className="rounded-lg border p-4"> <span className="text-sm text-gray-500">{label}</span> <span className="text-2xl font-bold">{value}</span> </div> ); }); export function Dashboard({ data, onExport }: DashboardProps) { // Manual useMemo for derived data const totals = useMemo(() => ({ revenue: data.reduce((sum, d) => sum + d.revenue, 0), users: data.reduce((sum, d) => sum + d.users, 0), orders: data.reduce((sum, d) => sum + d.orders, 0), }), [data]); // Manual useCallback to stabilize reference const handleExport = useCallback((format: string) => { onExport(format); }, [onExport]); // Manual useMemo for formatted values const formattedRevenue = useMemo( () => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(totals.revenue), [totals.revenue] ); return ( <div> <MetricCard label="Revenue" value={totals.revenue} /> <MetricCard label="Users" value={totals.users} /> <span>{formattedRevenue}</span> <button onClick={() => handleExport('csv')}>Export</button> </div> ); }

After: Compiler-Optimized

components/Dashboard.tsx (after)
'use client'; interface DashboardProps { data: DataPoint[]; onExport: (format: string) => void; } // No React.memo needed — compiler handles it function MetricCard({ label, value, }: { label: string; value: number; }) { return ( <div className="rounded-lg border p-4"> <span className="text-sm text-gray-500">{label}</span> <span className="text-2xl font-bold">{value}</span> </div> ); } export function Dashboard({ data, onExport }: DashboardProps) { // Plain computation — compiler memoizes automatically const totals = { revenue: data.reduce((sum, d) => sum + d.revenue, 0), users: data.reduce((sum, d) => sum + d.users, 0), orders: data.reduce((sum, d) => sum + d.orders, 0), }; // Plain function — compiler stabilizes the reference const handleExport = (format: string) => { onExport(format); }; // Plain formatting — compiler caches based on input const formattedRevenue = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(totals.revenue); return ( <div> <MetricCard label="Revenue" value={totals.revenue} /> <MetricCard label="Users" value={totals.users} /> <span>{formattedRevenue}</span> <button onClick={() => handleExport('csv')}>Export</button> </div> ); }

What Can Be Safely Removed

  • useMemo for derived data — the compiler tracks input dependencies automatically
  • useCallback for event handlers — reference stability is handled at compile time
  • React.memo on child components — the compiler prevents unnecessary re-renders
  • Complex dependency arrays — no more missed dependencies or stale closures

Important: Removing manual memoization is safe only when the compiler is enabled. Keep them if you need to support non-compiled builds.

Result
  • Cleaner, more readable components
  • Fewer lines of code to maintain
  • No more dependency array bugs

#4 The Rules of React the Compiler Enforces

The React Compiler relies on your code following the 'Rules of React.' If your components break these rules, the compiler may skip them or produce incorrect output. Understanding these rules is now more important than ever.

Rule 1: No Mutation During Render

Bad — mutates during render
function SortedList({ items }) { // ❌ Mutates the prop directly during render items.sort((a, b) => a.name.localeCompare(b.name)); return ( <ul> {items.map(item => <li key={item.id}>{item.name}</li>)} </ul> ); }
Good — creates new array
function SortedList({ items }) { // ✅ Creates a new sorted array — no mutation const sorted = [...items].sort( (a, b) => a.name.localeCompare(b.name) ); return ( <ul> {sorted.map(item => <li key={item.id}>{item.name}</li>)} </ul> ); }

Rule 2: Stable Hook Call Order

Bad — conditional hook
function Profile({ userId }) { // ❌ Hook called conditionally if (!userId) return <p>No user</p>; const user = useUser(userId); return <p>{user.name}</p>; }
Good — early return after hooks
function Profile({ userId }) { // ✅ Hook always called, guard after const user = useUser(userId); if (!userId || !user) return <p>No user</p>; return <p>{user.name}</p>; }

Rule 3: No External Mutable State in Render

Bad — reads mutable global
let renderCount = 0; function Counter() { // ❌ Reading and mutating external variable during render renderCount++; return <p>Rendered {renderCount} times</p>; }
Good — use useRef for instance values
function Counter() { // ✅ useRef for mutable values that don't affect render const renderCount = useRef(0); useEffect(() => { renderCount.current++; }); return <p>Counter component</p>; }

Checking Compliance with ESLint

The React team provides an ESLint plugin that catches rule violations before you even run the compiler:

npm install -D eslint-plugin-react-compiler
eslint.config.mjs
import reactCompiler from 'eslint-plugin-react-compiler'; export default [ { plugins: { 'react-compiler': reactCompiler, }, rules: { 'react-compiler/react-compiler': 'error', }, }, ];
Result
  • Catches violations before build
  • Ensures compiler can optimize every component
  • Enforces React best practices team-wide

#5 Performance Impact in Production

The compiler's impact isn't theoretical — it produces measurable improvements in real-world applications, particularly in components with frequent re-renders and complex derived state.

Where You See the Biggest Gains

  • List components with expensive mapping/filtering — compiler caches derived arrays
  • Form components with many fields — only changed fields trigger re-computation
  • Dashboard layouts with many child components — compiler prevents prop-triggered cascading re-renders
  • Components with complex Intl formatting — locale formatting is cached by input value
  • Callback-heavy interactive UIs — function references are automatically stabilized

Measuring Compiler Impact

next.config.ts
const nextConfig: NextConfig = { experimental: { reactCompiler: { // Enable compilation logging in development // to see which components are being compiled panicThreshold: 'NONE', }, }, };

Use React DevTools Profiler to compare render counts and durations before and after enabling the compiler. Focus on:

  • Total render count per interaction — should decrease significantly
  • Render duration for complex components — should drop for unchanged inputs
  • Committed renders vs skipped renders — more should be skipped

When the Compiler Won't Help

  • Network-bound operations — fetch latency is not a rendering problem
  • Components that legitimately need to re-render on every update (e.g., real-time tickers)
  • First render performance — memoization only helps on subsequent renders
  • Server Components — they don't re-render on the client, so memoization is irrelevant
Result
  • Measurable render count reduction
  • Lower interaction-to-paint latency (INP)
  • Reduced memory allocation from fewer re-computations

#6 Migration Strategy for Existing Codebases

Adopting the compiler in an existing project requires a systematic approach. Here's a proven migration path that minimizes risk.

Phase 1: Audit with ESLint

Install the ESLint plugin first and fix all violations before enabling the compiler:

# Install the plugin npm install -D eslint-plugin-react-compiler # Run across the codebase npx eslint --ext .tsx,.ts src/ # Common violations to fix: # - Mutating props or state during render # - Conditional hook calls # - Reading mutable globals in render

Phase 2: Enable in Annotation Mode

Start with opt-in mode and mark your most performance-critical components:

// next.config.ts experimental: { reactCompiler: { compilationMode: 'annotation', }, } // Then add 'use memo' to specific files // Start with: // 1. Heavy list/table components // 2. Form components with many fields // 3. Dashboard/analytics components

Phase 3: Enable Globally

Once confident, switch to full compilation and opt out specific files if needed:

// next.config.ts experimental: { reactCompiler: true, } // Opt out problematic files with 'use no memo' // at the top of the file if needed

Phase 4: Remove Manual Memoization

Gradually remove useMemo, useCallback, and React.memo from compiled components. The compiler handles these automatically, so manual memoization is now redundant (though harmless if left in place).

  • Remove React.memo wrappers — the compiler prevents unnecessary child re-renders
  • Remove useMemo for derived data — the compiler caches computations automatically
  • Remove useCallback for handlers — reference stability is compiler-managed
  • Keep useMemo for intentional semantic guarantees (e.g., stable object identity for context values)
Result
  • Risk-free incremental adoption
  • No big-bang migration required
  • Cleaner codebase after removing manual memoization

Common Misconceptions

  • "The compiler replaces React.memo" — Partially true. It handles the same optimization automatically, but React.memo still works and isn't harmful to keep
  • "I need to rewrite my code" — No. The compiler works with existing idiomatic React. Only rule violations need fixing
  • "It makes every component faster" — Only components that were re-rendering unnecessarily. If your component legitimately needs to re-render, the compiler won't skip it
  • "It eliminates the need to think about performance" — It eliminates memoization busywork, but architecture decisions (server vs client, data fetching strategy) still matter
  • "useMemo and useCallback will be deprecated" — No. They remain valid APIs. The compiler simply makes them unnecessary in most cases

Compiler vs Manual: When Manual Still Wins

While the compiler handles most cases, there are scenarios where manual control is still valuable:

  • Context values with object identity requirements — useMemo ensures stable references for context providers to prevent consumer re-renders
  • Expensive initializers — useState(() => heavyCompute()) runs once; the compiler doesn't optimize initial state
  • Web Worker communication — stable callback references for postMessage handlers may need explicit useCallback
  • Third-party library integration — some libraries depend on referential equality for callbacks passed as props

Key Takeaways

  • 1The React Compiler automatically memoizes components and hooks at build time — no code changes needed
  • 2It's more granular than manual useMemo/useCallback — it tracks individual expression dependencies
  • 3Enable in Next.js 16 with a single config line: experimental.reactCompiler = true
  • 4Use annotation mode ('use memo' directive) for incremental adoption in large codebases
  • 5Follow the Rules of React: no mutation during render, stable hook order, no mutable globals
  • 6Install eslint-plugin-react-compiler to catch violations before they cause issues
  • 7After enabling, you can safely remove useMemo, useCallback, and React.memo from compiled components
  • 8The compiler doesn't replace architectural decisions — server/client boundaries and data strategy still matter

Final Thoughts

The React Compiler is the most impactful change to React's developer experience since hooks. It removes one of the most tedious aspects of React development — manually tracking dependencies and wrapping values in memoization primitives — and replaces it with automatic, compile-time optimization that's more precise than what most developers would write by hand.

For teams on Next.js 16, adoption is trivial. For those with legacy codebases, the incremental path (ESLint audit → annotation mode → full compilation → cleanup) makes migration safe and measurable.

The future of React performance isn't about writing more memoization code — it's about writing clean, idiomatic components and letting the compiler handle the rest.

TL;DR
  • React Compiler = automatic useMemo + useCallback + React.memo at build time
  • One config line enables it in Next.js 16
  • Works with existing code — no rewrite needed
  • Follow the Rules of React and use the ESLint plugin
  • Adopt incrementally: ESLint → annotation mode → full → cleanup
  • Write clean components, let the compiler optimize

Related Reading