DEV Community

Cover image for Why React 19 Compiler Replaces useMemo and useCallback
Syed Anzar
Syed Anzar

Posted on

Why React 19 Compiler Replaces useMemo and useCallback

Why React 19 Compiler Replaces useMemo and useCallback

Most React developers reach for useMemo and useCallback when their app feels slow. They wrap values, add dependency arrays, and hope the re-renders stop. Sometimes it works. Often it doesn't. And the code gets harder to read.

React 19 Compiler changes this entirely. It's not a runtime optimization — it's a build-time compiler that understands your code better than you do.

The Problem Manual Memoization Never Solved

function FriendList({ friends }) {
  const onlineCount = useFriendOnlineCount();

  if (friends.length === 0) {
    return <NoFriends />;
  }

  return (
    <div>
      <span>{onlineCount} online</span>
      {friends.map((friend) => (
        <FriendListCard key={friend.id} friend={friend} />
      ))}
      <MessageButton />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When friends changes, React re-renders FriendList and every child — FriendListCard, MessageButton, everything. Even if onlineCount didn't change. Even if MessageButton has nothing to do with friends.

Manual memoization tries to fix this:

const FriendListCard = memo(function FriendListCard({ friend }) {
  return <div>{friend.name}</div>;
});

const handleClick = useCallback((id) => {
  onSelect(id);
}, [onSelect]);

const processed = useMemo(() => expensive(friends), [friends]);
Enter fullscreen mode Exit fullscreen mode

Three problems:

  1. Easy to get wrong — that arrow function () => handleClick(item) creates a new function every render, breaking memo on the child
  2. Tedious maintenance — every prop change needs dependency array updates
  3. Can't memoize conditionally — useMemo runs on every render if placed after an early return
function Component({ data }) {
  if (!data) return null;
  // This useMemo runs every render — pointless
  const processed = useMemo(() => expensive(data), [data]);
  return <div>{processed}</div>;
}
Enter fullscreen mode Exit fullscreen mode

The compiler solves all three.

How the Compiler Actually Works

React Compiler is a Babel plugin that runs at build time. It doesn't change React's runtime. It transforms your source code into optimized code that does the memoization for you.

The Pipeline: 7 Phases, 55+ Passes

Babel AST → HIR → SSA → Type/Effect Inference → Reactive Scopes → Reactive Function → Codegen → Optimized Babel AST
Enter fullscreen mode Exit fullscreen mode

Phase 1: Lowering (BuildHIR)

Converts Babel AST to High-level Intermediate Representation (HIR) — a control-flow graph with basic blocks. Each block has sequential instructions and a terminal (branch, return, throw).

Phase 2: SSA Conversion (EnterSSA)

Every variable assigned exactly once. Phi nodes inserted at control-flow merge points.

Phase 3: Type & Effect Inference

Infers types (Primitive, Object, Function, Array, Poly) and mutation/aliasing effects. Marks "reactive places" — values that come from props, hooks, or are used in JSX.

Phase 4: Reactive Scope Inference

Groups co-mutating variables into reactive scopes. A scope = a set of values that invalidate together. The compiler computes minimal dependencies for each scope.

Phase 5: Reactive Function Construction

Converts the CFG into a tree structure (ReactiveFunction) with explicit scope boundaries.

Phase 6: Reactive Function Optimization

Prunes unused scopes, merges scopes that always invalidate together, handles early returns.

Phase 7: Code Generation

Outputs Babel AST with inline memoization using useMemoCache (imported as _c from react/compiler-runtime).

What the Generated Code Looks Like

Your source:

function FriendList({ friends }) {
  const onlineCount = useFriendOnlineCount();
  return (
    <div>
      <span>{onlineCount} online</span>
      {friends.map((friend) => (
        <FriendListCard key={friend.id} friend={friend} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Compiled output (simplified):

import { c as _c } from "react/compiler-runtime";

function FriendList(props) {
  const $ = _c(3); // 3 cache slots

  // Scope 1: onlineCount
  let t0;
  if ($[0] !== props.friends) {
    t0 = useFriendOnlineCount();
    $[0] = props.friends;
    $[1] = t0;
  } else {
    t0 = $[1];
  }
  const onlineCount = t0;

  // Scope 2: JSX for the list
  let t1;
  if ($[2] !== props.friends) {
    t1 = props.friends.map((friend) => (
      <FriendListCard key={friend.id} friend={friend} />
    ));
    $[2] = props.friends;
  } else {
    t1 = $[2]; // cached JSX!
  }

  return (
    <div>
      <span>{onlineCount} online</span>
      {t1}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key observations:

  • No useMemo or useCallback calls — the compiler inlines conditional checks directly
  • Granular caching — each reactive scope gets its own cache slot
  • Conditional memoization works — early returns don't break it because the cache check is inline
  • JSX is memoized — the mapped array of elements is cached as a unit

Why This Beats Manual Memoization

Aspect Manual (useMemo/useCallback) React Compiler
Granularity Component or value level Expression level
Conditional code Breaks after early returns Works everywhere
Dependency tracking Manual, error-prone Automatic, data-flow based
Arrow function props Breaks memo children Stable by default
Code size Adds wrapper functions Inline checks, minimal overhead
Debugging Stack traces show hooks Source maps preserved, readable output

The compiler sees the entire data flow. It knows exactly which values depend on which props/state. Manual memoization only sees what you tell it.

The _c Cache Function

The generated code uses an internal React function:

const $ = _c(slotCount);
Enter fullscreen mode Exit fullscreen mode

_c (aka useMemoCache) creates an array of cache slots. Each slot holds [dependency, cachedValue]. On re-render, the compiler-generated if checks: if dependency changed, recompute; else reuse cached value.

This is not useMemo. No closure allocation. No dependency array object. Just a raw array and inline comparison. Lower overhead.

Rules of React: The Contract

The compiler only works on code that follows React's rules:

  1. Pure render functions — same inputs = same outputs
  2. No direct mutation — props.list.push(item) breaks inference
  3. Hooks at top level — no conditional useState()
  4. Side effects in useEffect — not in render

Violate these, and the compiler skips optimization for that component (or emits a build-time warning via eslint-plugin-react-compiler).

This is why the ESLint plugin is mandatory — it catches violations before the compiler runs.

What Happens to Your Existing useMemo/useCallback?

The compiler preserves them. It doesn't strip manual memoization. It adds its own on top.

// Before compilation
const processed = useMemo(() => expensive(data), [data]);

// After compilation — both exist
const $ = _c(2);
let t0;
if ($[0] !== data) {
  t0 = expensive(data); // compiler's version
  $[0] = data;
  $[1] = t0;
} else {
  t0 = $[1];
}
const processed = useMemo(() => expensive(data), [data]); // yours, unchanged
Enter fullscreen mode Exit fullscreen mode

React's guidance: leave existing memoization in place. Remove it gradually after verifying behavior.

When You Still Need useMemo/useCallback

Escape hatches exist for:

  1. Effect dependencies — if a memoized value controls a useEffect, you may need manual control to prevent over-firing
  2. Stable references for external libraries — some non-React code needs referential identity
  3. Expensive non-React computations — the compiler only memoizes React components/hooks
function Component({ onSelect }) {
  // Compiler memoizes this, but you need stable ref for effect
  const handleSelect = useCallback((id) => {
    onSelect(id);
  }, [onSelect]);

  useEffect(() => {
    analytics.track('select', handleSelect); // needs stable ref
  }, [handleSelect]);

  return <Button onClick={handleSelect} />;
}
Enter fullscreen mode Exit fullscreen mode

Build Tool Integration

  • Vite: @vitejs/plugin-react + babel-plugin-react-compiler
  • Next.js 15.3+: Built-in via SWC (experimental: { reactCompiler: true })
  • Rsbuild: Built-in support
  • Metro (React Native): Experimental
  • Webpack: Via Babel

The compiler is decoupled from Babel — SWC and Oxc support coming.

The Mental Model Shift

Stop thinking: "Where do I add useMemo?"

Start thinking: "Does my code follow the Rules of React?"

If yes, the compiler handles memoization. You write declarative code. The compiler makes it fast.

This is the point: React Compiler removes a concept from your mental model. You no longer need to understand memoization mechanics to get memoization benefits.

Try It

npm install -D babel-plugin-react-compiler eslint-plugin-react-compiler
Enter fullscreen mode Exit fullscreen mode

Add to Babel config:

{
  "plugins": [["babel-plugin-react-compiler", { "target": "19" }]]
}
Enter fullscreen mode Exit fullscreen mode

Add to ESLint:

{
  "plugins": ["react-compiler"],
  "rules": { "react-compiler/react-compiler": "error" }
}
Enter fullscreen mode Exit fullscreen mode

Run your build. Check the output. You'll see _c imports and inline cache checks.


Bottom line: React 19 Compiler isn't a "smart useMemo." It's a compiler that understands data flow at build time and generates optimal memoization automatically. The manual hooks become escape hatches, not the default.

Write React. Let the compiler optimize it.

Top comments (0)