Frontend / Performance / 10_memoization_heuristics.md

Memoization Heuristics — When useMemo/useCallback/memo Help vs Hurt

Updated 5 interview angles 7 min read source
On this page15
  1. What does useMemo actually do?
  2. When does useMemo not help?
  3. When useCallback?
  4. When does React.memo help?
  5. When does React.memo hurt?
  6. What’s “premature memoization”?
  7. How would you measure whether a useMemo is helping?
  8. What’s the React Compiler (formerly React Forget)?
  9. What’s useMemo for referential stability — concrete example.
  10. Context value memoization.
  11. Are useMemo/useCallback only React?
  12. Gotchas
  13. Related
  14. Further reading
  15. Interview angle

Memoization Heuristics — When useMemo/useCallback/memo Help vs Hurt

Memoization in React is not free — it costs a dep-array comparison every render, plus the memory to hold the cached value. The wins are real but narrow. useMemo / useCallback help when the value/function is expensive to compute or needs referential stability for a memo’d child or useEffect dep. React.memo helps when the component is expensive to render and its props are referentially stable. Outside those cases, blanket memoization adds complexity without measurable benefit and can hurt by preventing GC of cached values.

The mature rule: profile, then memoize. Don’t reach for useMemo/useCallback reflexively.

What does useMemo actually do?

Caches the result of a function across renders, recomputing only when a dep changes.

tsx
// toSorted() copies; items.sort() would mutate the prop in place
const sortedItems = useMemo(() => items.toSorted(comparator), [items, comparator]);

Each render, React shallow-compares the deps array to the previous render’s. Same → returns cached value; different → recomputes.

Cost: the comparison (cheap), the memory to hold the cached value, the slight risk of stale values if deps are wrong.

Benefit: skip recomputation. Worth it when the function is meaningfully expensive (sort/filter big array, parse big string, compute derived data).

When does useMemo not help?

  • Cheap computations — useMemo(() => a + b, [a, b]) costs more (comparison + memo) than just a + b.
  • Object/array creation alone — useMemo(() => ({ x, y }), [x, y]) is fine for downstream stability, but doesn’t itself “save” anything until a downstream memo/effect uses the stability.
  • Deps array unstable — if a dep is a fresh object/function every parent render, the memo never hits the cache. Dead weight.

The pattern that often surprises newcomers: useMemo doesn’t make code faster by itself. It makes code faster by being skipped when deps are unchanged. If deps are unstable, no benefit.

When useCallback?

Same heuristics as useMemo, but for functions:

tsx
const onClick = useCallback(() => save(id), [id]);

Use when:

  • Passing the function to a memo’d child that would otherwise re-render due to a new function reference.
  • Passing the function to useEffect’s dep array so the effect doesn’t re-fire every render.
  • Passing the function to a hook that caches by reference (e.g., custom hook subscribing to events).

Don’t bother for:

  • Functions passed to regular (non-memo’d) children that re-render anyway.
  • Top-level event handlers (onClick, onChange) where the receiving element doesn’t care about reference stability.

When does React.memo help?

Required conditions:

  1. The component is expensive to render (large tree, heavy DOM, or compute-heavy logic).
  2. Parent re-renders more often than the props change — without memo, the component re-renders on every parent render.
  3. Props are referentially stable — same identity when “the same” — or you provide a custom equality.
tsx
const ExpensiveRow = React.memo(function ExpensiveRow({ item, onSelect }) {
  return <div>{/* heavy rendering */}</div>;
});

For this to actually skip renders, the parent must pass the same item and onSelect references (use useMemo/useCallback upstream).

When does React.memo hurt?

Three modes:

  • Cheap component + unstable props — every render pays the shallow comparison cost and re-renders anyway. Net negative.
  • Complex props that need a custom comparator — memo(C, (a, b) => deepEqual(a, b.props)) — the comparator itself can cost more than the render.
  • Components where props change every render anyway — memo is useless dead code; readers wonder why it’s there.

What’s “premature memoization”?

Sprinkling useMemo/useCallback/memo across the codebase under the belief it’s “always a good idea.” It’s not — each adds a small cost (comparison, GC pressure) and, more importantly, adds complexity that the next reader has to reason about.

Mature codebases pick memoization deliberately:

  • After profiling shows a hot spot.
  • When passing values across a memo boundary.
  • When stabilizing context values or useEffect deps.

Outside those, it’s noise.

How would you measure whether a useMemo is helping?

  1. React Profiler with “why did this render?” enabled — see which components actually re-rendered and what triggered them.
  2. Compare renders with and without the memoization. Often the saved time is small.
  3. <Profiler> API — baseDuration - actualDuration is the memoization savings.

If the answer is “0.5ms saved on a 30ms commit,” remove it — the complexity isn’t worth it.

What’s the React Compiler (formerly React Forget)?

A compile-time optimizer, stable since React Compiler 1.0 (October 2025), that automatically memoizes: it analyses your components and inserts the equivalent of useMemo/useCallback/memo where they help. With it enabled, new code skips manual memoization; it bails out of components that break the rules of React (mutating props or state, as items.sort() above does), which is one more reason to write pure render code. Understanding why memoization helps (or doesn’t) is still the senior insight.

What’s useMemo for referential stability — concrete example.

tsx
function Dashboard({ items }) {
  // Without useMemo — new object every render
  const config = { sortBy: "date", filter: "active" };

  return <Chart items={items} config={config} />;
}

const Chart = React.memo(function Chart({ items, config }) {
  // expensive...
});

Chart re-renders on every Dashboard re-render because config is a new object every time. Fix:

tsx
function Dashboard({ items }) {
  const config = useMemo(() => ({ sortBy: "date", filter: "active" }), []);
  return <Chart items={items} config={config} />;
}

Now Chart only re-renders when items change. The useMemo here isn’t saving compute — it’s enabling memo to actually skip renders.

(Or hoist config outside the component as a const, which is cheaper. Hoisting trumps memoization when the value is truly constant.)

Context value memoization.

Every Context Provider value change re-renders all consumers. A non-memoized inline object defeats Context entirely:

tsx
// Bad — value identity changes every render → all consumers re-render
<MyContext.Provider value={{ user, setUser, settings }}>

// Better
const value = useMemo(() => ({ user, setUser, settings }), [user, settings]);
<MyContext.Provider value={value}>

Even better at scale: split the context so consumers only subscribe to what they need.

Are useMemo/useCallback only React?

The pattern is React-specific because of React’s re-render-by-default model. Vue’s reactivity is per-property — components only re-render when their reactive deps change, so most of this discussion doesn’t apply. Vue has v-memo and shallowRef for the rare cases — see Vue Performance — v-memo, shallowRef, markRaw, defineAsyncComponent. Svelte’s compile-time tracking sidesteps it entirely.

Gotchas

  • useMemo is not guaranteed — React docs explicitly say the cache may be dropped to free memory. Don’t rely on it for correctness.
  • Empty deps [] — value computed once for the component’s lifetime. Useful for constants; misleading if the function actually depends on something.
  • Stale closures in memoized callbacks — useCallback(() => fn(stateAtMount), []) captures the mount-time value. Add proper deps.
  • memo + children prop — children is a new reference every render unless wrapped or passed as a stable function. memo rarely helps for layout components that take children.
  • useReducer instead of useState when actions trigger many state updates — dispatchers are stable by default (no useCallback needed).
  • Object spread in render — { ...props, foo: 1 } creates a new object; downstream memo won’t help unless you useMemo the result.

Further reading

Interview angle 5

  • “When should you memoize?” - for one of three reasons, and you should be able to name which: an expensive computation, referential stability for a memo boundary, or referential stability for an effect dependency. “It felt like it might help” is not one of them.
  • “What does memoization cost?” - a dependency comparison on every render plus retained memory for the cached value, and for React.memo a shallow prop comparison per render. On cheap components that is a net loss, which is why blanket memoization can measurably slow an app down.
  • “How do you decide what to memoize?” - profile first. The DevTools Profiler with “why did this render” names the unstable prop or context value, and the fix is usually one useMemo at the source rather than memo on every consumer.
  • “Why does context need memoizing?” - because every consumer re-renders when the value’s identity changes, and an object literal in the provider is a new identity on every parent render. Memoize it, and past a certain size split the context by domain so unrelated changes do not ripple.
  • “Does the React Compiler change this advice?” - yes, substantially. React Compiler 1.0 has been stable since 2025-10 and auto-memoizes, so in a compiled codebase hand-written useMemo and useCallback are mostly noise that makes the code harder to read without making it faster.
    • Follow-up: “So is the mental model obsolete?” - no. You still need it to read a profile, to understand what the compiler did and did not memoize, and to work in the code that has not adopted it. The compiler changes what you write, not what you need to understand.