open atlas
↑ Back to track
React patterns, senior RXP · 09 · 01

When to memoize

Default to NOT memoizing. Reach for memo/useMemo/useCallback only when identity feeds a memoized child, when a value is another hook's dependency, or when a computation is measurably expensive — and profile before, not by reflex.

RXP Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Open almost any React codebase that’s been touched by more than two people and you’ll find it: every callback wrapped in useCallback, every derived value in useMemo, every component in React.memo — applied by reflex, “for performance”. Nobody profiled. Nobody can point to a render that got faster. The app isn’t measurably quicker; it’s measurably harder to read.

That reflex is the failure mode of this entire unit, so we start by killing it. Memoization is not free, it is not a default, and applied without a reason it is usually a net loss — slower than the recompute it replaced and noisier than the code it “optimized”. This lesson is the rule that keeps the rest of the unit honest: when to memoize, and — far more often — when not to.

Goal

After this lesson you can state the senior default — do not memoize — and name the exactly three situations that override it: identity passed to a React.memo’d child, a value that is another hook’s dependency, or a computation that is measurably expensive. You can explain why blanket memoization is often slower and always noisier, use the React Profiler to find a real problem before reaching for a fix, and recognize that most “we need memo” smells are actually bad state placement in disguise.

1

The default is: don’t. Memoization has a cost on every render, and most renders are already cheap. useMemo and useCallback don’t make work disappear — they trade work for bookkeeping. Each one allocates a closure, stores a dependency array, and runs a per-render comparison to decide whether to reuse the cached value. For a value that’s cheap to recompute, that bookkeeping costs more than just recomputing it.

// pure noise: the "expensive" computation is an addition
const total = useMemo(() => price + tax, [price, tax]);

// this is faster, smaller, and clearer — just compute it
const total = price + tax;

React even re-runs the dependency comparison and keeps the closure alive on every render. You paid for a cache to avoid an a + b. The senior baseline is a plain expression; memoization is the exception you must justify, not the habit you apply.

2

Override 1 — identity matters because the value crosses into a React.memo’d child. React.memo skips a child’s re-render when its props are shallowly equal to last time. But a new object, array, or function literal is a brand-new reference every render, so it fails that shallow check and the memo does nothing. Stabilizing that prop’s identity is the one case where useCallback/useMemo directly enables an optimization.

const Row = React.memo(function Row({ onSelect }: { onSelect: () => void }) {
  // re-renders only when onSelect's identity changes
  return <button onClick={onSelect}>Select</button>;
});

function List({ items }: { items: Item[] }) {
  // WITHOUT useCallback: new function each render → every memo'd Row re-renders anyway
  const onSelect = useCallback(() => {/* ... */}, []);
  return items.map((i) => <Row key={i.id} onSelect={onSelect} />);
}

The keyword is and: memoize the callback and wrap the child in memo. One without the other is half a pattern — a useCallback whose consumer isn’t memoized stabilizes an identity nobody checks.

3

Override 2 — the value is another hook’s dependency, so its identity controls correctness, not just speed. When an object or function appears in a useEffect, useMemo, or useCallback dependency array, a fresh identity every render re-fires the effect or invalidates the cache each time. Here you memoize to keep a dependency stable, which is about behavior — runaway effects, infinite loops — not micro-perf.

// options is a new object every render → effect re-runs every render
const options = useMemo(() => ({ pageSize, sort }), [pageSize, sort]);

useEffect(() => {
  const sub = subscribe(options);
  return () => sub.unsubscribe();
}, [options]); // now re-subscribes only when pageSize/sort actually change

Often the cleaner fix is to remove the dependency (inline the literal inside the effect, or follow “you might not need an effect”) rather than memoize it. Memoize the dependency only when it genuinely must be shared across the effect boundary.

4

Override 3 — the computation is measurably expensive, and you measured it. Sorting ten thousand rows, parsing a large blob, running a heavy reduce on every keystroke — that’s real work worth caching across renders. The load-bearing word is measurably: you confirmed it with the Profiler or a timing, you didn’t assume it. A useMemo here skips genuine CPU work; a useMemo on a .filter over twenty items skips nothing and adds bookkeeping.

// justified: filtering+sorting 10k rows on every keystroke is real work
const visible = useMemo(
  () => rows.filter(matches(query)).sort(byName),
  [rows, query],
);

If you can’t say which computation is slow and by how much, you don’t have override 3 — you have a guess. And a guess that wraps a cheap loop in useMemo makes the component slower, not faster.

Worked example

A real “perf” PR — most of it should be deleted. A teammate profiled nothing and “optimized” a search panel. Here’s the before:

function SearchPanel({ rows }: { rows: Row[] }) {
  const [query, setQuery] = useState("");

  // every line memoized "for performance"
  const onChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value),
    [],
  );
  const upper = useMemo(() => query.toUpperCase(), [query]);
  const placeholder = useMemo(() => `Search ${rows.length} rows`, [rows.length]);
  const visible = useMemo(
    () => rows.filter((r) => r.name.includes(query)),
    [rows, query],
  );

  return (
    <>
      <input value={query} onChange={onChange} placeholder={placeholder} />
      <p>{upper}</p>
      <PlainList rows={visible} /> {/* PlainList is NOT memo'd */}
    </>
  );
}

Now apply the three gates. onChange — gate 1 needs a memo’d consumer; the <input> is a host element, so the useCallback stabilizes an identity nobody checks → delete. upper and placeholder — string ops, not measurably expensive, no memo’d child, no dependency → delete. visible — gate 3, if rows is large enough to matter; but its consumer PlainList isn’t memoized, so the saved array identity buys no skipped render either. The honest move is to profile: if the filter is cheap, drop the useMemo; if rows is genuinely big, keep it and wrap PlainList in memo so the stable identity actually pays off.

function SearchPanel({ rows }: { rows: Row[] }) {
  const [query, setQuery] = useState("");
  const visible = useMemo(
    () => rows.filter((r) => r.name.includes(query)),
    [rows, query],
  ); // kept ONLY because profiling showed rows is large

  return (
    <>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder={`Search ${rows.length} rows`}
      />
      <p>{query.toUpperCase()}</p>
      <MemoList rows={visible} /> {/* now memo'd so the stable identity matters */}
    </>
  );
}

Four memos became one — and the one that survived is paired with a memo’d child so it actually does something. Less code, same or better performance, and the remaining useMemo documents a real cost instead of a reflex.

Why this works

Why is blanket memoization often slower, not just neutral? Because every useMemo/useCallback does real per-render work: it allocates the closure you pass it, keeps it alive, stores the dependency array, and runs a comparison loop each render to decide cache hit or miss. For a cheap value that bookkeeping exceeds the recompute it avoids — you added work to skip work that was smaller. It also blocks garbage collection of the cached value and adds a dependency array you must keep correct forever. Net: more allocations, more comparisons, more maintenance, for a computation that was a few nanoseconds. (The React Compiler changes this calculus by memoizing automatically where it’s safe — but that’s the compiler proving necessity, not you sprinkling it by hand.)

Common mistake

The deepest mistake is treating “we re-render too much” as a memoization problem when it’s almost always a state-placement problem. A piece of state sitting too high — at the top of a page when only a leaf input uses it — makes the whole subtree re-render on every keystroke. The reflex fix is to spray memo over the children to block the propagation. The senior fix is to move the state down to where it’s used (colocation) or lift content out as children so it isn’t re-created, and the re-renders vanish with zero memos. Reach for the Profiler first: if the flamegraph shows a wide tree re-rendering from a high state update, fix where the state lives before you cache anything. Memoization patches the symptom; correct state placement removes the cause.

Check yourself
Quiz

You add useCallback to a handler and pass it to a child component to 'stop the child re-rendering'. The child is a plain (non-memo'd) component. What actually happens?

Recap

The senior default is don’t memoizeuseMemo, useCallback, and React.memo all carry per-render cost (allocation, a stored dependency array, a comparison), and applied by reflex they’re usually slower than the recompute they replace and always harder to read. Override the default in exactly three cases: (1) an identity that crosses into a React.memo’d child — and only paired with that memo; (2) a value that is another hook’s dependency, where stability is about correctness, not speed; (3) a computation you have measured to be expensive. If none of the three holds, the memo is pure noise. And before optimizing at all, profile — a wide tree re-rendering on every keystroke is almost always bad state placement, fixed by moving state down or lifting content into children, not by spraying caches over the symptom. Memoize on evidence, not by habit.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.