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.
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.
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.
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.
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.
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.
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.
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.
// 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(...)).
We tested 20+ combinations. The pattern holds consistently:
| # | Definition → Consumer path | Hook between? | Memoized? |
|---|---|---|---|
| 1 | sorted → useState(sorted) |
no | ✅ |
| 2 | sorted → useCustomHook() → useState(sorted) |
yes | ❌ |
| 3 | useState(x) → sorted → useState(sorted) |
no (hook is before) | ✅ |
| 4 | useState(x) → sorted → useCustomHook() → useState(sorted) |
yes | ❌ |
| 5 | sorted → useState(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.
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.
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:
- Hooks must always execute (Rules of Hooks), so they can't be placed inside conditional cache blocks
- The compiler cannot reorder hook calls
- 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.
This is a silent degradation. The compiler:
- Reports no errors or warnings
- Passes
react-compiler-tracker --check-filescleanly - 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.
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.
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:
sortedSections-- sections sorted bysection_ordersectionIds/setSectionIds-- controlled state for DnD reorderingsortedSectionsForDnd-- 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.
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.
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.
- 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. - Extract cohesive concerns, not just the unmemoized expression. Including the
useStateconsumer in the same hook solves the memoization gap and improves cohesion. react-compiler-tracker --check-filesdoes not detect this class of issue. The only reliable way to verify memoization is to inspect the compiled output withbabel-plugin-react-compilerdirectly.
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} />
}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.
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.
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.
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.
react-compiler-tracker --check-fileshas no visibility into this. It only checks forCompileError/CompileDiagnostic/CompileSkipevents.- 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 > 0onCompileSuccessevents, or (b) inspecting the compiled output for bareconstdeclarations outside cache blocks. - The
debugLogIRstrace shows the responsible pipeline pass isFlattenScopesWithHooksOrUseHIR, which flattens (prunes) reactive scopes that contain hook calls.
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;
}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)
babel-plugin-react-compiler: 1.0.0react-compiler-runtime: 1.0.0- Target: React 18
- File:
src/react_components/project-view/components/board-layout/project-board-view.tsx