Memoization and React Compiler
Memoization compares identity, not content, and one fresh object in a dependency list defeats the whole chain below it.
Predict first
Before you read: predict
EXH-529, proctor console, ECON-201. The proctor grid shows 400 candidate tiles and an elapsed-time header that ticks every second. A proctor's laptop fan spins up during the exam, and the Profiler shows tile renders on every tick.
Development build, no StrictMode, no React Compiler. CandidateTile is wrapped in memo. Of the 400 candidates, 12 are flagged and 5 others arrived late.
The timer ticks once.
How many times does the CandidateTile function run?
function ProctorGrid({ candidates }) { // candidates: the same array on every render const [now, setNow] = useState(0); // +1 every second const [selected, setSelected] = useState(null); const rules = useMemo(() => ({ status: 'active' }), []); const extra = { minutes: 10 }; const onSelect = useCallback((id) => setSelected(id), []); const clearFlag = useCallback((id) => clear(id, rules), [rules]); const addTime = useCallback((id) => extend(id, extra), [extra]); return <main> <header>elapsed {now}s</header> {candidates.map((c) => <CandidateTile key={c.id} candidate={c} onSelect={onSelect} onClearFlag={c.flagged ? clearFlag : undefined} onAddTime={c.late ? addTime : undefined} />)} </main>;}The 30-second version
memo skips a child when every prop is Object.is-equal to last render. useMemo and useCallback return last render's value when every dependency is Object.is-equal, otherwise a new one. An object, array or function written directly in render is always new; a cached value is not. React Compiler may insert this caching for code following the Rules of React.
Memoization compares identity, not content, and one fresh object in a dependency list defeats the whole chain below it.
How it works
Step 1 of 8
Object.is treats objects, arrays and functions as equal only when they are one reference. A literal in render is new each time.Analogy: Make rebuilding by timestamp (engineering-native)
| In the analogy | In React |
|---|---|
make judges each prerequisite by its modification time, never its bytes |
A hook's dependency array, compared with Object.is |
| A target is skipped only if no prerequisite is newer; otherwise its recipe runs again | useMemo/useCallback return the cached value only if every dependency is the same reference |
A script that rewrites config.json with identical bytes gives it a new timestamp, so everything built from it rebuilds |
An object literal in render is new each time, so the hook rebuilds and every memo child receiving it re-renders |
| A generated file its rule leaves untouched keeps its old timestamp, so targets built from it stay current | A value returned by an unchanged useMemo keeps its identity, so hooks depending on it keep theirs |
| A rule for a millisecond-long step adds bookkeeping and saves nothing | Memoizing cheap work adds comparisons and saves nothing |
Breaks down when:
makecompares against outputs kept on disk across builds. A hook keeps only the previous render's value, and React may discard it. Wrong inference: "switching back to an old input reuses its old result."- There is no
makecounterpart to the Compiler (steps 7–8); the analogy covers the hooks only.
Contrasting case: a content-hashed cache, where the key is a hash of the bytes. Two equal files share one entry: deep equality, which memo and the hooks never do.
In real terms: memoization reuses a previous value when every input is Object.is-equal to the previous render's input, and React Compiler generates those checks for Rules-of-React-compliant components and hooks.
Examples, run for real
Each component calls track('<Name>'), a plain counter.
E1 · ExamHall: EXH-529, 400 tiles on every tick
EXH-529's inline onSelect arrow re-rendered all 400 memo tiles per tick; MR !806's useCallback(fn, []) brought that to 0, by the memo rule in react-render-commit step 2. What a dependency does to that fix:
const Tile = memo(function CandidateTile({ candidate, onSelect }) { track('CandidateTile'); /* … */ });
// in ProctorGrid, which re-renders every second:
candidates.map((c) => h(Tile, { key: c.id, candidate: c, onSelect: /* variant */ }))
onSelect passed to each tile |
Tile renders per tick (3 ticks) |
|---|---|
useCallback(fn, [filters]), filters = { … } written in render |
400, 400, 400 |
useCallback(fn, [filters]), filters = useMemo(() => ({ … }), []) |
0, 0, 0 |
A dependency written in render undoes the stable callback; one returned by useMemo keeps it. Selection works in every variant.
E2 · ExamHall: the same grid under React Compiler 1.0
No memo, useMemo or useCallback; an inline arrow per tile.
| Source | Compiler verdict for ProctorGrid |
Tile renders per tick, plain → compiled |
|---|---|---|
candidates.map(…) inline in the JSX |
CompileSuccess | 400 → 0 |
a filter step, then .map(…) inline in the JSX |
CompileSuccess | 400 → 400 |
the list built in its own statement (const tiles = …) |
CompileSuccess | 400 → 0 |
writes ticks.current during render |
CompileError: Cannot access refs during render (skipped) | 400 → 400 |
The Compiler removed the renders without hand-written memoization and skipped the rule-breaking component. The filtered shape compiled cleanly yet re-rendered every tile: 1.0.0 cached the whole returned JSX as one block that the timer invalidates. What gets cached is the compiler's heuristic, so count.
E3 · Proof domain, chat: memoization that can never skip
Thread re-renders on each poll. memo gets a counting comparator applying Object.is to every prop.
no memo, children JSX: per poll MessageBubble renders 20, memo comparisons 0
memo, children JSX: per poll MessageBubble renders 20, memo comparisons 20
memo, text string prop: per poll MessageBubble renders 0, memo comparisons 20
JSX children are new elements every render, so that memo pays for 20 comparisons and skips nothing.
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
ProductList computes const sorted = useMemo(() => sortProducts(products, sortKey), [products, sortKey]). Its parent CatalogPage renders <ProductList products={items.filter((p) => p.inStock)} sortKey={sortKey} />, where items never changes. After mount, three add-to-cart clicks re-render CatalogPage three times; the second click also changes sortKey from 'price' to 'name'.
How many times does sortProducts run during those three re-renders?
Question 2 of 7
Same page.
Name one change to CatalogPage that makes sortProducts run only when sortKey changes.
Question 3 of 7
A PR in Checkout adds const fee = useMemo(() => computeFee(amount), [amount]) (computeFee is one multiply and one add) and const onPay = useCallback(() => pay(amount + fee), [amount, fee]), passed to <PayButton onPay={onPay} />. PayButton is not wrapped in memo. Typing a promo code re-renders Checkout on every keystroke.
- ✓ "No gain here:
PayButtonisn't memoized, so it re-renders anyway, and the fee is trivial arithmetic. Drop both hooks, or profile first." - "Approve:
useCallbackstopsPayButtonre-rendering on each keystroke." → . Executed:PR (useMemo + useCallback): PayButton renders over 3 keystrokes 3andwithout the hooks: … 3. - "Approve:
useMemoguaranteescomputeFeeruns once per amount, so the fee can't drift." → . Executed:PR under StrictMode (development): computeFee calls on mount 2; react.dev: rely onuseMemoonly as a performance optimization.
Which review comment is right?
Question 4 of 7
Your streaming app turns on React Compiler 1.0. The player code already has hand-written useMemo and useCallback.
What does react.dev recommend for that existing code?
Question 5 of 7
The build runs babel-plugin-react-compiler 1.0.0 with default options. SeatMap holds a hold-expiry countdown in useState and renders 300 <Seat> with an inline onPick arrow; there is no memo, useMemo or useCallback. During render it also runs lastRendered = left;, assigning a variable declared at module level. The countdown ticks once.
How many times does Seat run?
Question 6 of 7
Development build, no StrictMode, no Compiler. A banking app's Statement({ transactions, now }) shows categorize(transactions) next to a "synced N s ago" clock. Its parent passes the same transactions array on every clock tick (once a second, with a new now) and a new array when a transaction arrives.
Which line caches the summary across clock ticks and still recomputes it when a transaction arrives?
Question 7 of 7
The build runs babel-plugin-react-compiler 1.0.0 with default options. Browse holds a viewer counter that ticks and a genre state, and starts with the 'use no memo' directive while the team debugs it. It renders <ChannelList channels={channels} filters={{ genre }} viewers={viewers} open={open} />. ChannelList computes const shown = pickChannels(channels, filters), builds const rows = shown.map((c) => <ChannelRow key={c.id} channel={c} onOpen={() => open(c.id)} />) and shows the viewer count. The Compiler log reports CompileSuccess for ChannelList, yet every tick still runs ChannelRow 200 times.
What is the cause?
Go deeper
Pro tips
- Lint for the rules the Compiler needs before trusting it (RAN).
- Trick: add
reactHooks.configs.flat.recommendedfromeslint-plugin-react-hooksto the ESLint flat config and lint the components. - When it helps: before enabling the Compiler; a flagged component is one it may skip (step 7).
- These are the files the Compiler logged as CompileError in E2 and P4.
- Status: RAN, eslint 9.39.1, eslint-plugin-react-hooks 7.1.1. It did not flag
grid-rule-break.jsx, which sorts its props array in place during render.
- Trick: add
- Confirm what the Compiler optimized (DOC, RDT-02).
- Trick: in a development build, open React DevTools' Components tab and look for the "Memo ✨" badge next to a component name.
- When it helps: before hand-writing
useMemo; and after a change, to see whether a component was skipped (step 8). - Verify: the E2 grid with the ref write should show no badge on
ProctorGrid; the compliant grid should. - Status: DOC (react.dev/learn/react-compiler/installation, fetched 2026-09-27). DevTools UI not exercised here.
- Measure before memoizing (DOC).
- Trick: wrap the calculation in
console.time('filter')/console.timeEnd('filter'), or record the interaction in the Profiler (RDT-03). - When it helps: deciding whether
useMemoormemopays at all (step 6). - Verify: react.dev's example figure is "say, 1ms or more", at which "it might make sense to memoize that calculation"; it is a hedge, not a threshold.
- Status: DOC (react.dev/reference/react/useMemo, fetched 2026-09-27). Timing was not measured here (brief rule 7).
- Trick: wrap the calculation in
Source
- react.dev:
memo. Fetched 2026-09-27. Covers theObject.isprop comparison, "a performance optimization, not a guarantee", whenmemois valuable, new objects and functions breaking it, and "When you enable React Compiler, you typically don't needReact.memoanymore." Valid as of 2026-09-27. - react.dev:
useMemoanduseCallback. Fetched 2026-09-27. CoverObject.ison dependencies, the cache that React "will not throw away … unless there is a specific reason", StrictMode calling the calculation twice, the hedged 1 ms figure ("say, 1ms or more … it might make sense to memoize"), and "useCallback caches the function itself". Valid as of 2026-09-27. - react.dev: React Compiler introduction. Fetched 2026-09-27. "By default, React Compiler will memoize your code based on its analysis and heuristics";
useMemo/useCallbackas an escape hatch; the existing-code recommendation. The pack's own "may" (30-second version, step 7, E2) is an observation from E2's filtered row, consistent with Debugging's "the compiler may memoize differently than your manual approach". Valid as of 2026-09-27. - react.dev: React Compiler v1.0 blog post (stable 1.0; React 17+; lint rules in
eslint-plugin-react-hooksrecommended) and Debugging (it "safely skips optimization" for code that might break the rules; runtime issues arise when it "mistakenly compiled a component it should have skipped"). Fetched 2026-09-27. Valid as of 2026-09-27.
Related lessons
Checked against the tools and docs as of 2026-09-27.