Skip to content

Instantly share code, notes, and snippets.

@Noitidart
Last active June 14, 2026 17:55
Show Gist options
  • Select an option

  • Save Noitidart/80d4c4c902a4f354c7924f88616bb4bc to your computer and use it in GitHub Desktop.

Select an option

Save Noitidart/80d4c4c902a4f354c7924f88616bb4bc to your computer and use it in GitHub Desktop.

React Compiler: What We Learned

A practical guide to understanding what the React Compiler does, when it helps, and when you still need memo().


What the React Compiler Does

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.

The two things it handles automatically

  1. Caching computed values — replaces manual useMemo
  2. Stabilizing function references — replaces manual useCallback

You no longer need to write either of these. The compiler does it for you.

What it does NOT replace

memo() — still needed in specific cases. Read on to understand why.


How to Read Compiler Output

Use the React Compiler Playground to see what the compiler does with your code.

Example 1: Static component (no state)

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.


The .map() Problem

This is where things get interesting. The compiler handles .map() differently depending on what the callback captures.

Case 1: .map() with no changing dependencies — compiler caches everything

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.

Case 2: .map() callback captures changing state — cache breaks

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:

  1. highlightedId changes (user hovers over an item)
  2. $[1] !== highlightedId → true → cache miss
  3. Entire .map() re-runs → new <Item> elements for ALL items
  4. React processes each new element → calls ALL Item functions
  5. Only ONE item's isHighlighted actually changed — the rest are wasted renders

Why memo() Fixes This

The compiler and memo() work at different levels:

The compiler: cache check INSIDE the function

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.

memo(): prop comparison BEFORE calling the function

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.

Visual comparison

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

The Rule: When to Use memo()

The signal pattern

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.

Common examples

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

When you do NOT need memo()

  • The .map() callback doesn't capture changing state — the compiler caches it with a sentinel. No memo() 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.
  • items data changes via setItems() — this is legitimate re-rendering, not wasted work. The compiler's internal cache handles unchanged items cheaply.

The simple test

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().


Why Context Doesn't Help

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)

Object Values: Optimized as a Whole, Not Field-by-Field

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.

Three levels of memoization

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.

Minimal reproduction

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.

Busted: makersden.io

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 fix: split contexts by change frequency

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.


The Playground Progression

These examples were built in the React Compiler Playground to understand how the compiler works step by step.

Step 1: Static component

function App() {
  return <ul />;
}

_c(1), sentinel cache. Created once, reused forever.

Step 2: Static .map()

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.

Step 3: Manual unroll instead of .map()

<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().

Step 4: Add useState that doesn't affect .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.

Step 5: Add useState that DOES affect .map()

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.

Step 6: Context approach

<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.


The Two "Memo" Badges in React DevTools

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.

How to tell them apart

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:

Badge in tooltip Visual What it means Function called?
Blue "Memo" with ✨ sparkles auto-memo React Compiler auto-memo (internal caching) Yes — cache check runs inside
Gray "Memo" (no sparkles) react-memo-only React.memo() (component-level prop comparison) No — skipped if props unchanged
Both badges side by side both Component has both memo() AND compiler Nomemo() gates the call

Proof from DevTools

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).

Profiler showing component rendered due to parent, despite auto-memo badge

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).

Profiler showing both Memo badges, rendered due to prop change

This proves: memo() prevents function calls when props haven't changed. The compiler badge just means the internals are additionally cached.

Mental model

  • 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"

Summary for React 19 + Compiler

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

The one rule to remember

Use memo() when a .map() callback captures state that changes frequently but only affects a small subset of items — the classic prop={state === item.id} pattern.

Write your components without useMemo/useCallback. Let the compiler handle those. Add memo() only when you spot the pattern.


Open Questions: Closures and Memory Leaks

There is more learning to be done around how the React Compiler interacts with JavaScript closures and potential memory leaks.

The problem

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.

Connection to our .map() findings

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.

Potential solution: hoisted buildMap function

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.

Playground experiment: does buildItems actually help?

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;
}

Findings from the three outputs

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment