A practical guide to understanding what the React Compiler does, when it helps, and when you still need memo().
The compiler runs at build time and transforms your components to add automatic caching. It inserts a cache (_c()) inside every component and hook, storing cached JSX elements, computed values, and function references.
- Caching computed values — replaces manual
useMemo - Stabilizing function references — replaces manual
useCallback
You no longer need to write either of these. The compiler does it for you.
memo() — still needed in specific cases. Read on to understand why.
Use the React Compiler Playground to see what the compiler does with your code.
Input:
function App() {
return <ul />;
}Output:
function App() {
const $ = _c(1); // 1 cache slot
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) { // sentinel = "first time?"
t0 = <ul />; // create once
$[0] = t0;
} else {
t0 = $[0]; // reuse forever
}
return t0;
}Key concepts:
_c(N)creates N cache slots. These persist across re-renders (tied to the component's fiber).Symbol.for("react.memo_cache_sentinel")is a placeholder meaning "this slot has never been initialized."- The first render creates the value and stores it. Every subsequent render returns the cached value.
There are two types of cache checks you'll see:
| Check type | Meaning | When it rebuilds |
|---|---|---|
=== Symbol.for("react.memo_cache_sentinel") |
"Has this ever been created?" | Never — created once, reused forever |
$[N] !== someValue |
"Has this value changed since last render?" | When someValue changes |
Sentinel checks are the best case — the compiler determined the value never needs to change.
This is where things get interesting. The compiler handles .map() differently depending on what the callback captures.
function App() {
const items = [
{ id: 1, label: 'A' },
{ id: 2, label: 'B' }
];
return (
<ul>
{items.map(item => (
<Item key={item.id} label={item.label} />
))}
</ul>
);
}The compiler hoists the .map() callback to a standalone function _temp and caches the entire <ul> with a sentinel check:
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
const items = [{ id: 1, label: "A" }, { id: 2, label: "B" }];
t0 = <ul>{items.map(_temp)}</ul>; // created ONCE
$[0] = t0;
} else {
t0 = $[0]; // reused forever
}
function _temp(item) {
return <Item key={item.id} label={item.label} />;
}Every re-render returns the same <ul> element reference. React sees "same object" and bails out instantly. Item functions are never called.
function App() {
const [highlightedId, setHighlightedId] = React.useState(null);
const items = [
{ id: 1, label: 'A' },
{ id: 2, label: 'B' }
];
return (
<ul>
{items.map(item => (
<Item
key={item.id}
label={item.label}
isHighlighted={highlightedId === item.id} // ← THE PROBLEM
/>
))}
</ul>
);
}The callback now captures highlightedId. The compiler cannot cache the .map() output because the callback depends on state that changes:
// No longer a sentinel — now a value comparison:
if ($[1] !== highlightedId) {
t1 = (
<ul>
{items.map((item) => (
<Item
key={item.id}
label={item.label}
isHighlighted={highlightedId === item.id}
/>
))}
</ul>
);
$[1] = highlightedId;
$[2] = t1;
} else {
t1 = $[2];
}Also notice: the callback is no longer hoisted to _temp. The compiler can't extract it because it captures highlightedId from the closure.
What happens at runtime:
highlightedIdchanges (user hovers over an item)$[1] !== highlightedId→ true → cache miss- Entire
.map()re-runs → new<Item>elements for ALL items - React processes each new element → calls ALL Item functions
- Only ONE item's
isHighlightedactually changed — the rest are wasted renders
The compiler and memo() work at different levels:
function Item(t0) {
const $ = _c(2);
const { label } = t0;
// Cache check happens HERE — inside the function body
if ($[0] !== label) {
t1 = <li>{label}</li>;
$[0] = label;
$[1] = t1;
} else {
t1 = $[1]; // returns cached <li>
}
return t1;
}The function is always called. The cache runs inside. It returns the cached result quickly, but the function body executes. A console.log at the top would still fire.
const Item = memo(function Item({ isHighlighted, label }) {
// This function is NEVER CALLED if props haven't changed
console.log('render'); // does NOT fire for unchanged items
return <li>{label}</li>;
});memo() wraps the component and adds a shallow prop comparison at the React fiber level. React compares old props vs new props before scheduling the render. If all props are identical, the function is never called.
Parent re-renders, creates new <Item> elements in .map()
│
▼
React reconciler processes each element
│
┌────────┴────────┐
│ │
memo()? No memo()
│ │
▼ │
Compare props │
│ │
┌───┴───┐ ▼
│ │ ALWAYS call function
Same Changed │
│ │ ┌────┴────┐
▼ ▼ │ │
SKIP Call Cache Cache
function hit? miss?
│ │
▼ ▼
Return Rebuild
cached JSX
JSX
| Optimization | Where it works | Function called? | console.log fires? |
|---|---|---|---|
Parent .map() cached (compiler) |
Before reconciler | No — same element reference | No |
memo() on child |
React fiber level | No — props unchanged | No |
| Compiler cache inside child | Inside function body | Yes — always called | Yes |
Use memo() when you see this pattern in a .map():
{items.map(item => (
<Item
isSomething={stateValue === item.id} // ← THIS IS THE SIGNAL
/>
))}A prop that combines changing state with the iterated item, where the state change only affects one (or a few) items but forces ALL items through the .map() to re-render.
isHighlighted={highlightedId === item.id} // hover highlight
isSelected={selectedId === item.id} // selection
isExpanded={expandedId === item.id} // accordion
isEditing={editingId === item.id} // inline edit
isActive={activeTab === tab.id} // tabs- The
.map()callback doesn't capture changing state — the compiler caches it with a sentinel. Nomemo()needed. - The state change affects ALL items (e.g., a filter or sort change) — all items legitimately need to re-render.
- The list is small (roughly < 20 items) — re-renders are imperceptible.
itemsdata changes viasetItems()— this is legitimate re-rendering, not wasted work. The compiler's internal cache handles unchanged items cheaply.
Ask yourself: "When this state changes, do ALL items in the list get new elements even though only ONE item's props actually changed?"
If yes → use memo().
You might think: "I'll move highlightedId to React Context so the .map() callback doesn't capture it."
const HighlightContext = React.createContext(null);
function App() {
const [highlightedId, setHighlightedId] = useState(null);
return (
<HighlightContext.Provider value={highlightedId}>
<ul>
{items.map(item => (
<Item key={item.id} id={item.id} label={item.label} />
))}
</ul>
</HighlightContext.Provider>
);
}
function Item({ id, label }) {
const highlightedId = useContext(HighlightContext);
// ...
}The good news: the .map() callback no longer captures highlightedId. The compiler goes back to a sentinel cache and hoists the callback to _temp. The <ul> is created once and never rebuilt.
The bad news: context changes bypass memo() and the compiler's element-level bailout. When highlightedId changes, React forces ALL Item consumers to re-render. The function is called for every Item regardless.
In fact, context + memo() is worse than props + memo() — because context bypasses memo()'s prop comparison entirely.
| Approach | .map() cached? |
Items called on hover? |
|---|---|---|
isHighlighted prop, no memo() |
No | All items |
isHighlighted prop + memo() |
No, but memo() skips unchanged |
Only changed items |
Context, no memo() |
Yes | All items (context forces) |
Context + memo() |
Yes, but memo() bypassed by context |
All items (context forces) |
React Compiler optimizes object values — whether from context providers, hook returns, or any other source — at the object level (the entire value as a single unit), not at the field level (individual properties within it). If any field changes, the entire object gets a new reference, and every consumer re-renders — even those that don't read the changed field.
| Level | What it means | React Compiler does this? |
|---|---|---|
| Prop-level | Checks each individual prop before re-rendering a memoized child. If prop A changed but prop B didn't, only parts depending on A re-render. | Yes |
| Return-level (hooks/context values) | Memoizes the entire context/hook return value as a single unit. All inputs stable → same reference. Any input changes → new reference. | Yes |
| Field-level (within an object) | Narrows inside the object: "consumer only reads setCount, so skip it when count changes." |
No |
React Compiler prevents the context value object from getting a new reference when its inputs haven't changed (return-level). But once any input changes, the whole object is new, and all useContext consumers re-render. useContext compares with Object.is on the entire value — it cannot subscribe to individual fields.
StackBlitz demo / GitHub issue #170:
const CountContext = createContext<{ count: number; setCount: (u: (c: number) => number) => void } | null>(null);
function CountProvider({ children }) {
const [count, setCount] = useState(0);
return <CountContext.Provider value={{ count, setCount }}>{children}</CountContext.Provider>;
}
// Reads only setCount — does NOT read count
function IncrementButton() {
const { setCount } = useContext(CountContext);
// This re-renders every time count changes, even though it doesn't read it
return <button onClick={() => setCount((c) => c + 1)}>Increment</button>;
}When count changes, the { count, setCount } object gets a new reference. IncrementButton re-renders even though it only reads setCount. If field-level optimization existed, it wouldn't.
The article React Compiler: What you need to know about Automatic Memoization claims under "Context and Selector-Style Subscriptions":
For large context values, the compiler narrows subscriptions: It treats each property of a context object like a separate prop, only re-subscribing to changed values.
This confuses return-level memoization (keeping the whole object's reference stable when deps don't change) with field-level subscription (narrowing per-field re-renders). The former is real; the latter is not. The official React docs at react.dev/useContext still recommend useMemo + useCallback for context value stability.
The only way to get per-value isolation is separate contexts. Each useContext subscribes to one value. Group fields that always change together; split fields with different change frequencies.
These examples were built in the React Compiler Playground to understand how the compiler works step by step.
function App() {
return <ul />;
}→ _c(1), sentinel cache. Created once, reused forever.
function App() {
const items = [{ id: 1, label: 'A' }, { id: 2, label: 'B' }];
return (
<ul>
{items.map(item => <Item key={item.id} label={item.label} />)}
</ul>
);
}→ _c(1), sentinel cache. Callback hoisted to _temp. Entire <ul> cached.
<ul>
<Item key={items[0].id} id={items[0].id} label={items[0].label} />
<Item key={items[1].id} id={items[1].id} label={items[1].label} />
</ul>→ _c(2) — compiler splits into separate slots: items array ($[0]) and <ul> ($[1]). More granular than .map().
const [count, setCount] = React.useState(0);
// ...
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<ul>{items.map(...)}</ul>→ _c(7) — compiler creates a dependency chain. Button is gated on count. <ul> stays sentinel-cached (it doesn't depend on count). When count changes, button rebuilds but <ul> is reused.
const [highlightedId, setHighlightedId] = React.useState(null);
// ...
<Item isHighlighted={highlightedId === item.id} />→ _c(3) — the <ul> is now gated on $[1] !== highlightedId. When highlightedId changes, the entire <ul> with the .map() is rebuilt. All Item functions called.
<HighlightContext.Provider value={highlightedId}>
<ul>{items.map(item => <Item key={item.id} id={item.id} label={item.label} />)}</ul>
</HighlightContext.Provider>→ _c(4) — the .map() goes back to sentinel cache (_temp hoisted again). But at runtime, context change forces all consumers to re-render.
React DevTools shows two visually distinct "Memo" badges. Once you know the difference, you can tell at a glance which kind of memoization a component has.
There are two ways to identify which kind of memoization a component has — one by looking at the component list or flame chart (no hover needed), and one by hovering to see the tooltip badges.
Without hovering — look at the component list or flame chart:
- ✨ sparkles icon at the start of the row/bar → compiler auto-memo
- (Memo) after the component name →
React.memo() - Both ✨ sparkles icon and (Memo) → component has both
With hovering — look at the tooltip badges:
We observed the following in our real app:
Compiler auto-memo only (blue sparkles badge, no gray badge):
The profiler showed "Why did this render? The parent component rendered." — the function was called even though no props changed. The "Memo" badge was there, but it only means the cache check runs inside the function body. The function was still called, and the profiler recorded a duration (0.1ms).
This proves: compiler auto-memo does NOT prevent function calls. It makes them cheap, but they still happen.
memo() + compiler auto-memo (both badges):
The profiler showed "Why did this render? Props changed: (isHighlighted)" — the function was called only because a prop genuinely changed. Items where isHighlighted stayed false were skipped entirely (no duration recorded, no log fired).
This proves: memo() prevents function calls when props haven't changed. The compiler badge just means the internals are additionally cached.
- Blue ✨ Memo = "React knocks, and the door opens instantly with a cached answer" (function called, but returns cached result)
- Gray Memo = "React doesn't even knock" (function skipped entirely when props are the same)
- Both = "React only knocks when props genuinely changed, and then gets a fast cached answer"
| Manual optimization | Still needed? | Why |
|---|---|---|
useMemo |
No | Compiler caches computed values automatically |
useCallback |
No | Compiler stabilizes function references automatically |
memo() |
Yes, for the "one active item in a list" pattern | Only way to prevent the function call entirely |
useMemo/useCallback as escape hatch |
Rare | Fine-grained control over effect dependencies |
Use
memo()when a.map()callback captures state that changes frequently but only affects a small subset of items — the classicprop={state === item.id}pattern.
Write your components without useMemo/useCallback. Let the compiler handle those. Add memo() only when you spot the pattern.
There is more learning to be done around how the React Compiler interacts with JavaScript closures and potential memory leaks.
All closures inside a component share a single context object (the scope). If the compiler caches any closure, it keeps the entire scope alive — including variables that other closures reference but the cached one doesn't need. If any of those variables is a large object, it can't be garbage collected as long as the cached closure exists.
This is not a React-specific problem — it's fundamental to how JavaScript closures work. But React's heavy use of closures combined with the compiler's automatic memoization makes it easier to encounter.
For a deep dive, see Sneaky React Memory Leaks: How the React compiler won't save you by Kevin Schiener.
In our playground experiments, we observed that when the .map() callback captured highlightedId from the component scope, the compiler could no longer hoist it to _temp. The callback became an inline arrow function tied to the component's closure scope.
If highlightedId were a large object instead of a small string, this closure capture could theoretically keep old instances alive through the compiler's cached values — exactly the leak described in the article.
One approach to avoid closure-related issues: extract the .map() into a top-level function that takes all values as explicit parameters instead of capturing them from scope:
// Top-level — no closure capture from component scope
function buildItems({ items, highlightedId, onHover }) {
return items.map(item => (
<Item
key={item.id}
isHighlighted={highlightedId === item.id}
onHover={onHover}
/>
));
}
// Inside component — passes values explicitly
function App() {
const [highlightedId, setHighlightedId] = useState(null);
return <ul>{buildItems({ items, highlightedId, onHover: setHighlightedId })}</ul>;
}This eliminates the shared closure scope — highlightedId is a parameter, not a captured variable. No context object to keep alive.
This is similar to the bind(null, x) approach from the article, but more ergonomic.
We tested all three variations in the React Compiler Playground to see how the compiler handles each one.
Input 1 — .map() with no changing state (the baseline, callback hoisted to _temp):
function App() {
const items = [
{ id: 1, label: 'A' },
{ id: 2, label: 'B' }
];
return (
<ul>
{items.map(item => (
<Item key={item.id} label={item.label} />
))}
</ul>
);
}
function Item({ id, label }) {
return <li>{label}</li>;
}Compiler output:
function App() {
const $ = _c(1);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
const items = [
{ id: 1, label: "A" },
{ id: 2, label: "B" },
];
t0 = <ul>{items.map(_temp)}</ul>;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
function _temp(item) {
return <Item key={item.id} label={item.label} />;
}Everything is cached in a single sentinel slot. The .map() callback is hoisted to _temp. Created once, reused forever.
Input 2 — .map() with highlightedId captured in closure (the problem):
function App() {
const [highlightedId, setHighlightedId] = React.useState(null);
const items = [
{ id: 1, label: 'A' },
{ id: 2, label: 'B' }
];
return (
<ul>
{items.map(item => (
<Item
key={item.id}
label={item.label}
isHighlighted={highlightedId === item.id}
/>
))}
</ul>
);
}
function Item({ label, isHighlighted }) {
return <li>{label}</li>;
}Compiler output:
function App() {
const $ = _c(3);
const [highlightedId] = React.useState(null);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = [
{ id: 1, label: "A" },
{ id: 2, label: "B" },
];
$[0] = t0;
} else {
t0 = $[0];
}
const items = t0;
let t1;
if ($[1] !== highlightedId) {
t1 = (
<ul>
{items.map((item) => (
<Item
key={item.id}
label={item.label}
isHighlighted={highlightedId === item.id}
/>
))}
</ul>
);
$[1] = highlightedId;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
}_temp is gone — the callback is now an inline arrow that captures highlightedId from the component's closure scope. The entire <ul> is gated on $[1] !== highlightedId. This is where the closure capture happens — if highlightedId were a large object, it would be kept alive through the cached values.
Input 3 — hoisted buildItems with explicit parameters (the proposed solution):
function buildItems({ items, highlightedId }) {
return items.map(item => (
<Item
key={item.id}
label={item.label}
isHighlighted={highlightedId === item.id}
/>
));
}
function App() {
const [highlightedId, setHighlightedId] = React.useState(null);
const items = [
{ id: 1, label: 'A' },
{ id: 2, label: 'B' }
];
return (
<ul>
{buildItems({ items, highlightedId })}
</ul>
);
}
function Item({ label, isHighlighted }) {
return <li>{label}</li>;
}Compiler output:
// buildItems is NOT compiled — no _c(), no caching inside
function buildItems({ items, highlightedId }) {
return items.map((item) => (
<Item
key={item.id}
label={item.label}
isHighlighted={highlightedId === item.id}
/>
));
}
function App() {
const $ = _c(4);
const [highlightedId] = React.useState(null);
let t0;
if ($[0] !== highlightedId) {
const items = [
{ id: 1, label: "A" },
{ id: 2, label: "B" },
];
t0 = buildItems({ items, highlightedId });
$[0] = highlightedId;
$[1] = t0;
} else {
t0 = $[1];
}
let t1;
if ($[2] !== t0) {
t1 = <ul>{t0}</ul>;
$[2] = t0;
$[3] = t1;
} else {
t1 = $[3];
}
return t1;
}1. buildItems is not compiled.
The compiler only adds _c() cache slots to components and hooks. buildItems is a plain function — no _c(), no internal caching, no _temp hoisting. The .map() inside it stays as a regular inline arrow function. The compiler simply ignores it.
2. The items array is no longer cached separately.
Compare the items handling across the three outputs:
| Output | How items is cached |
|---|---|
| Input 1 (no state) | Inside the sentinel block — created once, reused forever |
| Input 2 (closure) | Separate sentinel slot $[0] — cached independently |
| Input 3 (buildItems) | Inside the $[0] !== highlightedId block — recreated on every highlight change |
In input 3, the compiler moved const items = [...] inside the if ($[0] !== highlightedId) block. This means every time highlightedId changes, a new items array is created — even though items never actually changes.
3. The compiler caches the buildItems result, but not the function itself.
App stores the return value of buildItems() in $[1], and the <ul> wrapper in $[3]. When highlightedId changes, buildItems is called again (because $[0] !== highlightedId), which re-runs the entire .map(). The compiler caches the result, but can't avoid recomputing it.
4. The closure concern is addressed, but at a cost.
With buildItems, highlightedId is passed as a parameter, not captured from the component scope. This means no shared closure context — a large object passed as a parameter would not be kept alive by other closures in the component. This is the memory leak benefit.
But the trade-off is: buildItems re-runs the full .map() on every highlightedId change (same as the inline approach), AND loses the separate items caching. The compiler can't optimize inside buildItems because it's not a component.
5. When to use which approach:
| Scenario | Recommended approach |
|---|---|
Small values (strings, booleans, numbers) in .map() closure |
Inline .map() + memo() on child — closure capture is harmless for small values |
Large objects in .map() closure, memory leak risk |
buildItems({ ... }) to avoid closure capture |
No changing state in .map() closure |
Plain .map() — compiler caches everything with _temp |
Status: The buildItems approach has been verified in the compiler playground but not yet tested with memory profiling. It needs real-world validation to confirm the memory leak prevention outweighs the loss of compiler optimizations.








