Siddhant Deval
Siddhant Deval
frontend5 min read

The React Compiler: Memoization Automation & the New Mental Model

The React Compiler does not make useMemo and useCallback obsolete — it makes manual memoization at the component level unnecessary, while the engineer's job shifts to ensuring component purity so the compiler can safely apply automatic memoization.

Series·Part 9 of 9

Modern Web Rendering & Performance

The React Compiler: Memoization Automation & the New Mental Model

Every React developer learns memoization as a performance primitive: wrap values in useMemo, wrap callbacks in useCallback, wrap components in React.memo. The mental model is clear — these wrappers create stable references that prevent unnecessary re-renders. The problem is the execution: which values need wrapping, with which dependencies, and whether a dependency is missing or stale. A useMemo with a wrong dependency array is worse than no useMemo — it silently returns stale values. A useCallback with an empty dependency array closes over stale state. Engineers add wrappers defensively, without measuring, producing components that are harder to read and no faster.

The React Compiler (React Forget) automates this analysis. It performs static analysis on your component source code, identifies which values are referentially stable, and inserts fine-grained memoization at the call site — without dependency arrays, without useMemo wrapping, and at a granularity that human engineers cannot practically achieve manually. The catch: the compiler can only safely memoize components that are pure. Purity is now a correctness requirement enforced at build time.


1. The Broken Pattern: Defensive Memoization Without Measurement

TYPESCRIPT
// ❌ Broken — over-memoized component with a stale closure bug
'use client'
import { useMemo, useCallback, memo, useState } from 'react'

interface Item {
  id: string
  name: string
  price: number
}

const ItemRow = memo(({ item, onSelect }: { item: Item; onSelect: (id: string) => void }) => {
  return <li onClick={() => onSelect(item.id)}>{item.name}</li>
})

function ItemList({ items, discount }: { items: Item[]; discount: number }) {
  const [selected, setSelected] = useState<string | null>(null)

  // ❌ useMemo with wrong deps — discount is not in the dependency array
  // filteredItems will not update when discount changes
  const filteredItems = useMemo(() => {
    return items.filter(item => item.price * (1 - discount) < 100)
  }, [items]) // ← discount is missing

  // ❌ useCallback closes over stale `selected` due to empty deps
  const handleSelect = useCallback((id: string) => {
    console.log('Previously selected:', selected) // Always logs null
    setSelected(id)
  }, []) // ← selected is missing, but adding it recreates the callback on every render

  return <ul>{filteredItems.map(item => <ItemRow key={item.id} item={item} onSelect={handleSelect} />)}</ul>
}

This component has three compounding problems:

  1. filteredItems is stale when discount changes — a silent correctness bug
  2. handleSelect always reads selected as null — another correctness bug
  3. The memo and useCallback add cognitive overhead without measurable performance benefit unless ItemRow is genuinely expensive to render

The memoization is defensive, incorrect, and untested.


2. Enabling the React Compiler

Note: As of React Compiler v1.0 (October 2025), the compiler's lint rules are bundled into eslint-plugin-react-hooks — no separate eslint-plugin-react-compiler package is needed. Use the latest eslint-plugin-react-hooks to get compiler purity diagnostics alongside hooks rules.

BASH
# Step 1: Install the ESLint plugin (includes compiler purity rules as of v1.0)
npm install --save-dev eslint-plugin-react-hooks@latest

# Step 2: Install the compiler babel plugin
npm install --save-dev babel-plugin-react-compiler
JAVASCRIPT
// eslint.config.js — react-hooks plugin now includes compiler purity rules
import reactHooks from 'eslint-plugin-react-hooks'

export default [
  {
    plugins: { 'react-hooks': reactHooks },
    rules: {
      ...reactHooks.configs.recommended.rules,
      // Compiler purity rules are included in react-hooks@latest:
      'react-hooks/react-compiler': 'error', // Fail on purity violations
    },
  },
]
TYPESCRIPT
// next.config.ts — enable the React Compiler in Next.js 15
import type { NextConfig } from 'next'

const config: NextConfig = {
  experimental: {
    reactCompiler: true,
    // OR for incremental adoption — opt in per directory/file:
    reactCompiler: {
      compilationMode: 'annotation', // Only compile files with 'use memo' directive
    },
  },
}
export default config
TYPESCRIPT
// babel.config.js — for Vite or CRA projects
module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', {
      target: '18', // React version: '17', '18', or '19'
      // compilationMode: 'annotation' — for incremental adoption
    }],
  ],
}
Pro Tip & Optimization

Use compilationMode: 'annotation' to opt in per-file during migration. Add 'use memo' at the top of files you want the compiler to process. Once all purity violations are fixed across the codebase, remove the annotation mode and compile everything.


3. What the React Compiler Does

The compiler reads your component source code and performs two analyses:

  1. Referential stability analysis: Which values, derived values, and callbacks produce the same reference when inputs have not changed?
  2. Purity verification: Are all components and hooks pure (same inputs → same output, no side effects during render)?

For values that are referentially stable and produced by pure components, the compiler inserts a c(N) memo cache:

TYPESCRIPT
// Source — what you write:
function ProductCard({ product }: { product: Product }) {
  const discountedPrice = product.price * 0.9
  const label = `${product.name} — ${formatPrice(discountedPrice)}`

  return (
    <div>
      <h3>{label}</h3>
      <span>{formatPrice(discountedPrice)}</span>
    </div>
  )
}

// Compiled output (simplified) — what the compiler produces:
function ProductCard(t0) {
  const $ = useMemoCache(6) // c(6) — a cache array with 6 slots
  const { product } = t0

  let t1 // discountedPrice
  if ($[0] !== product.price) {
    t1 = product.price * 0.9  // Only recomputed when product.price changes
    $[0] = product.price
    $[1] = t1
  } else {
    t1 = $[1] // Cached value — no recomputation
  }

  let t2 // label
  if ($[2] !== product.name || $[3] !== t1) {
    t2 = `${product.name} — ${formatPrice(t1)}`
    $[2] = product.name
    $[3] = t1
    $[4] = t2
  } else {
    t2 = $[4]
  }

  let t3 // JSX output
  if ($[5] !== t2) {
    t3 = <div><h3>{t2}</h3><span>{formatPrice(t1)}</span></div>
    $[5] = t3
  }
  return t3 // If product hasn't changed, same JSX reference returned — no re-render
}

The key observation: memoization is inserted at the call site of individual values, not at the component boundary. The compiler knows that discountedPrice only changes when product.price changes, and label only changes when product.name or discountedPrice changes. This is finer-grained than any useMemo a human would write.

You can inspect the compiled output for any component using the React Compiler Playground: playground.react.dev. Paste your component source and see exactly what c(N) cache slots the compiler assigns and what triggers each cache miss.


4. The Purity Contract

The React Compiler's static analysis is conservative: if it cannot prove a component is pure, it skips memoization entirely for that component (it does not produce incorrect output — it simply produces unoptimized output, identical to the source).

What violates purity:

TYPESCRIPT
// ❌ Mutating props — impure, compiler skips
function BadComponent({ items }: { items: Item[] }) {
  items.push({ id: 'extra', name: 'Added' }) // ← Mutation during render
  return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>
}

// ❌ Reading from a mutable external ref during render — impure
let globalCounter = 0
function Counter() {
  globalCounter++ // ← Side effect during render
  return <span>{globalCounter}</span>
}

// ❌ Calling non-pure functions during render — impure if the function has side effects
function DataRow({ id }: { id: string }) {
  const data = fetchDataSynchronously(id) // ← Side effect (I/O during render)
  return <div>{data.name}</div>
}

// ✅ Pure — same inputs always produce the same output
function ProductCard({ product }: { product: Product }) {
  const price = product.price * 0.9           // ← Pure derivation
  return <div>{product.name}: {price}</div>   // ← Deterministic output
}

The eslint-plugin-react-compiler surfaces purity violations as ESLint errors before compilation — fix them there, not by adding "use no memo" escape hatches.


5. Reading Compiler Output to Diagnose Cache Misses

When the compiler memoizes a component but re-renders are still frequent, the c(N) cache slots reveal which input is causing cache misses:

TYPESCRIPT
// Example: why is this component re-rendering on every parent render?

// Compiled output reveals:
if ($[0] !== props.onClose) {  // ← onClose is in slot 0
  // ...compute expensive JSX
  $[0] = props.onClose
}

// If onClose is created inline in the parent: onClick={() => setOpen(false)}
// → New function reference on every parent render
// → $[0] !== props.onClose is always true
// → Cache always misses
// → No benefit from compiler memoization for this component

// Fix: move onClose to a stable Server Action or use useCallback in the parent
// (or, after the compiler, the parent's own compiled output will stabilize onClose)
Architectural Note

When a parent component is also compiled, the compiler may stabilize onClose at the parent's call site — making the useCallback in the parent unnecessary. Check whether the parent is being compiled before adding manual useCallback to stabilize a prop.


6. When useMemo and useCallback Are Still Required

The compiler cannot memoize values that escape the React render tree — values passed to non-React APIs, external subscriptions, or browser APIs:

TYPESCRIPT
// ❌ The compiler cannot see through this — useCallback still required
function MapComponent({ onLocationChange }: { onLocationChange: (loc: LatLng) => void }) {
  const map = useRef<google.maps.Map>(null)

  // This callback is passed to the Google Maps SDK — not to React
  // The compiler cannot analyze what Google Maps does with it
  // If onLocationChange is recreated every render, Maps SDK re-subscribes every render
  const handleMapClick = useCallback((e: google.maps.MapMouseEvent) => {
    onLocationChange(e.latLng!)
  }, [onLocationChange]) // ← Still needed — external SDK subscription

  useEffect(() => {
    if (!map.current) return
    const listener = map.current.addListener('click', handleMapClick)
    return () => google.maps.event.removeListener(listener)
  }, [handleMapClick]) // ← handleMapClick in deps — useCallback is the correct tool here
}
TYPESCRIPT
// ❌ Values used in useEffect dependency arrays with external stores — useMemo still needed
function StoreConnectedComponent({ filter }: { filter: Filter }) {
  // computedQuery escapes React — it's passed to an external store subscription
  const computedQuery = useMemo(() => buildQuery(filter), [filter])

  useEffect(() => {
    const subscription = externalStore.subscribe(computedQuery, handleUpdate)
    return () => subscription.unsubscribe()
  }, [computedQuery]) // ← computedQuery must be stable across renders
}

7. React.memo After the Compiler

The compiler memoizes the rendered output at the call site (in the parent). React.memo prevents re-renders triggered by the parent rendering. After the compiler, both mechanisms can be simultaneously useful:

TYPESCRIPT
// The compiler memoizes PriceTag's JSX output inside ProductCard
// so ProductCard's render does not recreate PriceTag's JSX on every render.

// React.memo on PriceTag prevents re-renders when ProductCard re-renders
// for reasons unrelated to the props PriceTag cares about.

// Both are useful: the compiler optimizes the output creation,
// React.memo optimizes the component invocation from the parent.

const PriceTag = React.memo(({ price, currency }: { price: number; currency: string }) => {
  return <span>{formatPrice(price, currency)}</span>
})
// This is not redundant with the compiler — they optimize at different levels

8. "use no memo" and forwardRef in React 19

"use no memo" — The Escape Hatch

TYPESCRIPT
function DebugComponent({ data }: { data: unknown }) {
  'use no memo' // ← Opts this component out of compiler optimization entirely
  // Use temporarily when debugging to isolate whether the compiler is causing unexpected behavior
  // Remove once debugging is complete — do not leave in production code
  console.log('render:', data)
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}

[!CAUTION] "use no memo" is a debugging tool, not a solution for purity violations. If the compiler is skipping a component due to an impurity, use eslint-plugin-react-compiler to identify and fix the violation. Using "use no memo" to suppress compiler warnings leaves the component unoptimized permanently.

forwardRef in React 19

forwardRef is deprecated in React 19. Refs are now passed as regular props:

TYPESCRIPT
// React 18 and earlier — forwardRef wrapper required
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  return <input ref={ref} {...props} />
})

// React 19 — ref is a regular prop, no wrapper needed
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />
}
// The compiler handles both patterns during migration — no "use no memo" needed

Summary

Concept Rule
Enable with ESLint first Install eslint-plugin-react-compiler before enabling the compiler. Fix all purity violations before compilation — not with "use no memo".
What the compiler does Static analysis → inserts c(N) cache at value call sites, not at component boundaries. Dependency-array-free. Finer-grained than manual useMemo.
Purity contract Same inputs → same output, no side effects during render. Compiler silently skips impure components — ESLint warnings are the only signal.
useMemo / useCallback remaining use cases Values that escape the React render tree: external SDK subscriptions, useEffect deps referencing external stores, browser API integrations.
React.memo + compiler Not redundant — the compiler optimizes output creation at the call site; React.memo prevents parent-triggered re-renders. Both can be needed simultaneously.
"use no memo" Debugging tool only. Leaving it in production means the component is permanently unoptimized. Fix the purity violation instead.
React Compiler Playground playground.react.dev — paste component source, inspect c(N) cache slots, verify what was and wasn't memoized.

Series Complete

You've reached the end of Modern Web Rendering & Performance. The series has taken you from the React Flight protocol (Part 1) through Server Actions and Progressive Enhancement (Part 2), PPR's static/dynamic composition (Part 3), Streaming SSR's onShellReady contract (Part 4), Islands Architecture and resumability (Part 5), LCP priority scheduling (Part 6), INP's three-phase model (Part 7), CLS geometry reservation (Part 8), and the React Compiler's purity contract (Part 9).

The series mindset holds throughout: Rendering is a scheduling contract. Every boundary you draw is a promise to the browser's scheduler. The scheduler always collects.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#React#React Compiler#React Forget#Memoization#Performance#useMemo
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.