Skip to content

Instantly share code, notes, and snippets.

@frankieyan
Last active March 6, 2026 22:03
Show Gist options
  • Select an option

  • Save frankieyan/ee72b71de6a307b38d71134d14f044dc to your computer and use it in GitHub Desktop.

Select an option

Save frankieyan/ee72b71de6a307b38d71134d14f044dc to your computer and use it in GitHub Desktop.
React Compiler: memoization gap when hook calls separate a value from its hook consumer

React Compiler: memoization gap when hook calls separate a value from its hook consumer

Summary

The React Compiler silently skips memoization of a derived value when hook calls appear between the value's definition and a hook that consumes it. This produces correct but unnecessarily unstable references, which can break patterns that depend on referential stability (e.g., useConditionalEffect in drag-and-drop).

The compiler reports no errors or warnings. The file compiles cleanly and passes react-compiler-tracker --check-files. The only signal is a runtime behavioral regression.

How we found it

The symptom

After extracting inline temp ID mapping into custom hooks (useMappedSections, useMappedTasksBySectionId) and removing explicit useMemo from sortedSections in ProjectBoardView, the Playwright board-layout section reordering test failed consistently (3/3 runs). Sections snapped back to their pre-drag positions.

The mechanism

BoardViewDndEffects uses useConditionalEffect with projectSections (i.e., sortedSections) in its dependency array. When the user stops dragging, isDragging flips to false, triggering a re-render. If sortedSections has a new reference on that render, the effect fires and resets sectionIds to the stale Redux order (the async sync update hasn't landed yet), undoing the visual reorder.

Previously, sortedSections was wrapped in useMemo and held a stable reference across re-renders. After removing useMemo (trusting the React Compiler), the reference became unstable.

Initial assumptions

Both files (use-temp-id-resolution.ts, project-board-view.tsx) compile cleanly under the React Compiler:

$ react-compiler-tracker --check-files --show-errors src/.../use-temp-id-resolution.ts
✅ No new React Compiler errors in checked files

$ react-compiler-tracker --check-files --show-errors src/.../project-board-view.tsx
✅ No new React Compiler errors in checked files

Neither file appears in .react-compiler.rec.json. We expected the compiler to auto-memoize sortedSections based on sections, equivalent to the old useMemo.

Investigation

Step 1: verify the compiler memoizes simple cases

We compiled a minimal component with babel-plugin-react-compiler directly:

function Simple({ sections }: { sections: Section[] }) {
    const sorted = [...sections].sort((a, b) => a.section_order - b.section_order)
    return <div>{sorted.map((s) => s.name)}</div>
}

Compiled output:

function Simple(t0) {
    const $ = c(4)
    const { sections } = t0
    let t1
    if ($[0] !== sections) {
        // ✅ memoized
        const sorted = [...sections].sort(_temp)
        t1 = sorted.map(_temp2)
        $[0] = sections
        $[1] = t1
    } else {
        t1 = $[1]
    }
    // ...
}

The compiler correctly memoizes sorted based on sections. This rules out spread-sort syntax as the cause.

Step 2: compile the actual component

We compiled project-board-view.tsx through the same babel pipeline and searched for sortedSections:

// Line 245: useState before
const [controlled, setControlled] = useState(tasksBySectionIdWithoutSubTasks);
// Line 246: NOT inside any cache block!
const sortedSections = [...sections].sort(_temp3);
// Line 248: next cache block starts
if ($[26] !== viewOptions) { ... }

sortedSections is not memoized. It runs unconditionally on every render, producing a new array reference each time. All downstream cache checks against sortedSections (e.g., $[93] !== sortedSections, $[180] !== sortedSections) always evaluate to true, making them no-ops.

Step 3: isolate the cause

We built a series of progressively complex test cases to pinpoint what prevents memoization. Each case was compiled through babel-plugin-react-compiler and the output inspected for cache guards around the sort expression.

A: value consumed by useState and used in render body

function CaseA({ sections }: { sections: Section[] }) {
    const sorted = [...sections].sort(compareFn)
    const [ids, setIds] = useState(sorted.map((s) => s.id))
    return <div>{sorted.map((s) => s.name)}</div>
}

Result: ❌ not memoized. sorted computed fresh every render.

However, when sorted is passed as a JSX prop instead of consumed via .map() in the return:

function CaseF({ sections }: { sections: Section[] }) {
    const sorted = [...sections].sort(compareFn)
    const [ids, setIds] = useState(sorted.map((s) => s.id))
    return <Child sections={sorted} />
}

Result: ✅ memoized. The compiler stores the useState function reference itself in the cache and calls it outside the block:

if ($[0] !== sections) {
    sorted = [...sections].sort(_temp)
    t1 = _react.useState // stores useState ref
    t2 = sorted.map(_temp2) // caches the initializer
    $[0] = sections
    $[1] = sorted
    $[2] = t1
    $[3] = t2
} else {
    sorted = $[1]
    t1 = $[2]
    t2 = $[3]
}
t1(t2) // always calls useState (Rules of Hooks)

This "store the hook reference" trick is how the compiler creates a scope that includes a useState consumer.

Narrowing: what about hooks between the value and its consumer?

// No hook between sorted and useState(sorted) → ✅ memoized
function Works({ sections, data }) {
    const [controlled] = useState(data)
    const sorted = [...sections].sort(compareFn)
    const [ids] = useState(sorted.map((s) => s.id))
    return <Child sections={sorted} />
}

// Custom hook between sorted and useState(sorted) → ❌ not memoized
function Breaks({ sections, data }) {
    const [controlled] = useState(data)
    const sorted = [...sections].sort(compareFn)
    const [other] = useCustomHook(true) // ← this breaks it
    const [ids] = useState(sorted.map((s) => s.id))
    return <Child sections={sorted} />
}

This is the decisive test. The only difference is a hook call between sorted and useState(sorted.map(...)).

Step 4: comprehensive matrix

We tested 20+ combinations. The pattern holds consistently:

# Definition → Consumer path Hook between? Memoized?
1 sorteduseState(sorted) no
2 sorteduseCustomHook()useState(sorted) yes
3 useState(x)sorteduseState(sorted) no (hook is before)
4 useState(x)sorteduseCustomHook()useState(sorted) yes
5 sorteduseState(sorted)useCustomHook(sorted) no (between def and first consumer)
6 sorted → two useCustomHook() calls → useState(sorted) yes

The rule: the compiler can create a memoization scope that wraps a value and its first hook consumer, but only if no other hook calls intervene.

Step 5: mapping to the real component

In ProjectBoardView, between sortedSections and useState(sortedSections.map(...)):

sortedSections = [...sections].sort(...)        // defined
  sortingOptionsEnabled = hasSorting...(...)     // pure computation
  editorStateMode = editorState?.mode            // pure computation
  activeEditorSectionId = ...                    // pure computation
  isEditorStateModeAdding = ...                  // pure computation
  archivedSections = useAppSelector(...)         // ← HOOK #1
  { completedTasksManager } = useProjectCompletedTasks(...)  // ← HOOK #2
  isProjectReadOnly = ...                        // pure computation
  hasUncategorizedCompletedTasks = ...           // pure computation
  hasUncategorizedTasks = ...                    // pure computation
  projectIsEmpty = useMemo(...)                  // ← HOOK #3
useState(sortedSections.map(s => s.id))          // consumed

Three hook calls (useAppSelector, useProjectCompletedTasks, useMemo) sit between the definition and the consumer. This matches the pattern from our test cases exactly.

Root cause

The React Compiler builds reactive scopes (memoization blocks) by grouping related computations. When a value is consumed by a hook (like useState), the compiler can include that hook in the scope by storing the hook function reference in the cache and calling it outside the block.

However, this trick only works when there are no intervening hook calls between the value's definition and its consumer. Hook calls are treated as hard scope boundaries because:

  1. Hooks must always execute (Rules of Hooks), so they can't be placed inside conditional cache blocks
  2. The compiler cannot reorder hook calls
  3. The "store hook reference" trick only applies to the consuming hook, not to unrelated hooks that happen to sit in between

When intervening hooks exist, the compiler gives up on creating a scope for the value. It falls through to computing the value unconditionally on every render. This is semantically correct but produces an unstable reference, which breaks downstream patterns that depend on referential stability.

Impact

This is a silent degradation. The compiler:

  • Reports no errors or warnings
  • Passes react-compiler-tracker --check-files cleanly
  • Produces correct output (the value IS computed, just not cached)

The only observable effect is that reference-sensitive patterns stop working: useEffect deps fire unexpectedly, useConditionalEffect resets state, child components don't skip re-renders, etc.

This makes it unsafe to remove useMemo purely based on the compiler's clean compilation status. A file may compile without errors but still fail to memoize specific values due to this structural limitation.

Fix applied

What didn't work: extract a pure function as a hook

Our first attempt extracted just the sort into a useSortedSections hook:

function useSortedSections(sections: Section[]): Section[] {
    return [...sections].sort(
        (sectionA, sectionB) => sectionA.section_order - sectionB.section_order,
    )
}

The compiler skipped this function entirely (no $ cache variable, no _reactCompilerRuntime.c() call). Because the function body contains no React hook calls, the compiler doesn't recognize it as a hook that needs instrumentation, despite the use prefix. The result is a new array on every render, which is worse than the original useMemo.

Key finding: The React Compiler only instruments functions it identifies as hooks, which requires at least one hook call in the function body. A use-prefixed function with no hook calls compiles as a plain function.

What worked: extract a hook with enough state to include a real hook call

We expanded the hook to encapsulate the full section-ordering concern, including the useState call that was the original consumer of sortedSections:

function useSortedSections(sections: Section[]) {
    const sortedSections = [...sections].sort(
        (sectionA, sectionB) => sectionA.section_order - sectionB.section_order,
    )

    const [sectionIds, setSectionIds] = useState(sortedSections.map((section) => section.id))

    const sortedSectionsForDnd = sectionIds.map((id) =>
        sections.find((section) => section.id === id),
    ) as Section[]

    return { sortedSections, sectionIds, setSectionIds, sortedSectionsForDnd }
}

This groups three related pieces of section-ordering logic:

  1. sortedSections -- sections sorted by section_order
  2. sectionIds / setSectionIds -- controlled state for DnD reordering
  3. sortedSectionsForDnd -- sections ordered by current DnD state

Including useState makes the compiler recognize the function as a real hook. The compiled output shows a 13-slot cache with proper guards:

function useSortedSections(sections) {
    const $ = _reactCompilerRuntime.c(13);
    let sortedSections;
    if ($[0] !== sections) {
        sortedSections = [...sections].sort(_temp);
        // ... caches sortedSections at $[1]
        $[0] = sections;
        $[1] = sortedSections;
    } else {
        sortedSections = $[1];
    }
    // useState called unconditionally (Rules of Hooks)
    const [sectionIds, setSectionIds] = t0(t1);
    // sortedSectionsForDnd cached on sectionIds + sections
    if ($[4] !== sectionIds || $[5] !== sections) { ... }
}

sortedSections is now cached at $[1] and only recomputed when sections changes. The intervening-hook problem is eliminated because the sort and its useState consumer are in the same function with no other hooks between them.

Other workarounds (not used)

Explicit useMemo

const sortedSections = useMemo(
    () => [...sections].sort((a, b) => a.section_order - b.section_order),
    [sections],
)

The compiler respects useMemo as a hook boundary and creates its own scope for the memoized value. This works but goes against the project convention of letting the compiler handle memoization.

Reorder declarations

Move the value definition right before its hook consumer, eliminating the intervening hooks:

// Move hooks before sorted
const archivedSections = useAppSelector(...)
const { completedTasksManager } = useProjectCompletedTasks(...)
const projectIsEmpty = useMemo(...)

// Now sorted and useState are adjacent -- compiler can create a scope
const sortedSections = [...sections].sort(compareFn)
const [sectionIds, setSectionIds] = useState(sortedSections.map((s) => s.id))

This is "compiler-compatible" but changes the declaration order, which may hurt readability or conflict with other dependency chains.

Lessons learned

  1. The compiler only instruments functions with hook calls. A use-prefixed function without any hook calls in its body is treated as a plain function. Always verify compiler output when extracting hooks.
  2. Extract cohesive concerns, not just the unmemoized expression. Including the useState consumer in the same hook solves the memoization gap and improves cohesion.
  3. react-compiler-tracker --check-files does not detect this class of issue. The only reliable way to verify memoization is to inspect the compiled output with babel-plugin-react-compiler directly.

Minimal reproduction

import { useState } from 'react'

function useOther() {
    return useState(0)
}

// ❌ Compiler does NOT memoize `sorted` — new reference every render
function Broken({ items }: { items: number[] }) {
    const sorted = [...items].sort((a, b) => a - b)
    const [other] = useOther() // hook between def and consumer
    const [ids, setIds] = useState(sorted.join(','))
    return <Child data={sorted} />
}

// ✅ Compiler DOES memoize `sorted` — stable reference
function Working({ items }: { items: number[] }) {
    const [other] = useOther() // hook moved before def
    const sorted = [...items].sort((a, b) => a - b)
    const [ids, setIds] = useState(sorted.join(','))
    return <Child data={sorted} />
}

Verification method

To check whether the compiler memoizes a specific value, compile the file with babel-plugin-react-compiler and search for cache guards:

node -e "
const babel = require('@babel/core');
const fs = require('fs');
const code = fs.readFileSync('path/to/file.tsx', 'utf8');
const result = babel.transformSync(code, {
  filename: 'file.tsx',
  presets: [
    ['@babel/preset-react', { runtime: 'automatic' }],
    '@babel/preset-typescript',
  ],
  plugins: [['babel-plugin-react-compiler', { target: '18' }]],
});
console.log(result.code);
"

A memoized value will appear inside an if ($[N] !== dep) block with a corresponding $[M] = value assignment. An unmemoized value will appear as a bare const outside any cache block.

Logger verification

We compiled the minimal reproduction with a custom logger attached to babel-plugin-react-compiler to verify that the compiler emits no user-facing signal when memoization is pruned.

Logger interface

The compiler's Logger type has exactly two methods:

type Logger = {
    logEvent: (filename: string | null, event: LoggerEvent) => void;
    debugLogIRs?: (value: CompilerPipelineValue) => void;
}

logEvent receives a LoggerEvent union of 8 event kinds:

Event kind Purpose
CompileSuccess Function compiled successfully
CompileError Compilation failed
CompileDiagnostic Non-fatal diagnostic (e.g., eslint rule violations)
CompileSkip Function skipped (e.g., opt-out directive)
PipelineError Internal pipeline failure
Timing Performance measurement
AutoDepsDecorations Auto-deps decorations for effects
AutoDepsEligible Effect eligible for auto-deps

debugLogIRs is optional and receives the raw intermediate representation at every pipeline stage (139 calls for our 3-function repro). This is the only way to see which pipeline pass prunes a scope, but it requires parsing the compiler's internal IR format.

There are no other hooks, callbacks, or extension points.

Results

Compiling both Broken and Working from the minimal reproduction:

=== ALL LOGGER EVENTS ===
CompileSuccess: 3 event(s)
  fn=useOther slots=0 blocks=0 values=0 prunedBlocks=0 prunedValues=0
  fn=Broken  slots=2 blocks=1 values=1 prunedBlocks=1 prunedValues=3
  fn=Working slots=6 blocks=2 values=4 prunedBlocks=0 prunedValues=0

All three functions emit only CompileSuccess. Zero CompileDiagnostic, zero CompileSkip, zero CompileError, zero PipelineError events.

The only trace of pruned memoization is in the CompileSuccess event's metrics:

Metric Broken Working
memoSlots 2 6
memoBlocks 1 2
memoValues 1 4
prunedMemoBlocks 1 0
prunedMemoValues 3 0

Broken has 1 pruned memo block with 3 pruned values: the compiler identified a memoization scope, then pruned it because the intervening hook call made it impossible to execute conditionally. But it reports this only as a CompileSuccess metric, not as any kind of warning or diagnostic.

What this means

  • react-compiler-tracker --check-files has no visibility into this. It only checks for CompileError/CompileDiagnostic/CompileSkip events.
  • The eslint plugin (eslint-plugin-react-compiler) similarly does not surface pruned memoization.
  • The only detection method is either: (a) attaching a custom logger and checking prunedMemoBlocks > 0 on CompileSuccess events, or (b) inspecting the compiled output for bare const declarations outside cache blocks.
  • The debugLogIRs trace shows the responsible pipeline pass is FlattenScopesWithHooksOrUseHIR, which flattens (prunes) reactive scopes that contain hook calls.

Compiled output comparison

Broken -- sorted is bare, not inside any cache block:

function Broken(t0) {
  const $ = c(2);
  const { items } = t0;
  const sorted = [...items].sort(_temp);     // ❌ no cache guard
  useOther();
  useState(sorted.join(","));
  let t1;
  if ($[0] !== sorted) {                     // always true (new ref)
    t1 = jsx(Child, { data: sorted });
    $[0] = sorted;
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  return t1;
}

Working -- sorted is inside a cache block guarded by items:

function Working(t0) {
  const $ = c(6);
  const { items } = t0;
  useOther();
  let sorted;
  let t1;
  let t2;
  if ($[0] !== items) {                      // ✅ cache guard on items
    sorted = [...items].sort(_temp2);
    t1 = _react.useState;                    // "store hook ref" trick
    t2 = sorted.join(",");
    $[0] = items; $[1] = sorted; $[2] = t1; $[3] = t2;
  } else {
    sorted = $[1]; t1 = $[2]; t2 = $[3];
  }
  t1(t2);                                    // always calls useState
  let t3;
  if ($[4] !== sorted) {
    t3 = jsx(Child, { data: sorted });
    $[4] = sorted; $[5] = t3;
  } else {
    t3 = $[5];
  }
  return t3;
}

Existing React issues

Three existing issues describe this behavior:

  • #31631 -- "React Hook placement prevents memoization of dependent variables" -- OPEN, Status: Unconfirmed, no response from React team (filed Nov 2024)
  • #34369 -- "Invocation of hook at specific position breaks memoization" -- CLOSED (auto-stale, Dec 2025). Has authoritative explanation from josephsavona (React Compiler team): the compiler identifies the memoization scope but prunes it because it contains a hook call that cannot be made conditional. He confirmed they're exploring fixes (code reordering, conditional hook calls with runtime cooperation, re-entrant memoization blocks) post-stable release.
  • #35355 -- "Compiler doesn't memoize if const is between hooks" -- CLOSED (spam, playground link was default text)

Environment

  • babel-plugin-react-compiler: 1.0.0
  • react-compiler-runtime: 1.0.0
  • Target: React 18
  • File: src/react_components/project-view/components/board-layout/project-board-view.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment