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>
);
}
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]);
Three problems:
-
Easy to get wrong — that arrow function
() => handleClick(item)creates a new function every render, breakingmemoon the child - Tedious maintenance — every prop change needs dependency array updates
-
Can't memoize conditionally —
useMemoruns 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>;
}
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
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>
);
}
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>
);
}
Key observations:
-
No
useMemooruseCallbackcalls — 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);
_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:
- Pure render functions — same inputs = same outputs
-
No direct mutation —
props.list.push(item)breaks inference -
Hooks at top level — no conditional
useState() -
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
React's guidance: leave existing memoization in place. Remove it gradually after verifying behavior.
When You Still Need useMemo/useCallback
Escape hatches exist for:
-
Effect dependencies — if a memoized value controls a
useEffect, you may need manual control to prevent over-firing - Stable references for external libraries — some non-React code needs referential identity
- 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} />;
}
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
Add to Babel config:
{
"plugins": [["babel-plugin-react-compiler", { "target": "19" }]]
}
Add to ESLint:
{
"plugins": ["react-compiler"],
"rules": { "react-compiler/react-compiler": "error" }
}
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)