Skip to content

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?

Code for this question
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>;}
Pick one

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

Identity, not content. 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:

  • make compares 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 make counterpart 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

a tailor cuts a long brown fabric along a curved tan paper pattern with a scalloped end. On the left, a stack of brown fabric carries a second pattern of a different, straight-sided outline with a round tab. On the right, a teal fabric piece lies with no pattern on it, next to a separate pattern with a cut-out window; a small hooked pattern piece, scissors, an awl and a tape roll sit on the table. The right third of the image is empty.
An illustration to help it stick. The mechanism is in the sections above.

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?

Pick one

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: PayButton isn't memoized, so it re-renders anyway, and the fee is trivial arithmetic. Drop both hooks, or profile first."
  • "Approve: useCallback stops PayButton re-rendering on each keystroke." → . Executed: PR (useMemo + useCallback): PayButton renders over 3 keystrokes 3 and without the hooks: … 3.
  • "Approve: useMemo guarantees computeFee runs once per amount, so the fee can't drift." → . Executed: PR under StrictMode (development): computeFee calls on mount 2; react.dev: rely on useMemo only 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?

Pick one

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?

Pick one

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?

Pick one

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?

Pick one

Go deeper

Pro tips
  1. Lint for the rules the Compiler needs before trusting it (RAN).
    • Trick: add reactHooks.configs.flat.recommended from eslint-plugin-react-hooks to 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.
  2. 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.
  3. 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 useMemo or memo pays 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).
Source
  • react.dev: memo. Fetched 2026-09-27. Covers the Object.is prop comparison, "a performance optimization, not a guarantee", when memo is valuable, new objects and functions breaking it, and "When you enable React Compiler, you typically don't need React.memo anymore." Valid as of 2026-09-27.
  • react.dev: useMemo and useCallback. Fetched 2026-09-27. Cover Object.is on 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/useCallback as 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-hooks recommended) 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.

Checked against the tools and docs as of 2026-09-27.