← cd ..

useMemo is not free: a field guide to React re-renders

June 25, 2026·8 min read#react#javascript#performance

Every large React codebase I've worked on eventually grows a layer of useMemo and useCallback that nobody can justify. Someone hit a perf problem once, sprinkled memoization until the symptom went away, and now it's cargo cult. New hooks get wrapped in useMemo on creation "to be safe." It's not safe. It's noise, and occasionally it's a regression.

The fix isn't "stop memoizing." It's understanding what a re-render actually costs, so you know when memoization buys you something and when it's just overhead with a comforting name.

What a re-render actually is

A re-render is React calling your component function again. That's it. It runs the function, gets back a new tree of elements, and compares it against the previous tree. This is the render phase, and it happens in memory — no DOM is touched.

Only if that comparison turns up actual differences does React enter the commit phase, where it mutates the real DOM. This is the distinction people miss: a re-render is not a DOM update. A component can re-render a hundred times and touch the DOM zero times if the output is the same each time. The expensive part of the browser's job — layout, paint — only happens on commit, when something genuinely changed.

So when someone says "this component re-renders too much," the right first question is: is the render slow, or is it committing real DOM changes? They have completely different fixes, and useMemo only addresses one of them.

What triggers a re-render

A component re-renders when:

  1. Its own state changes — a useState setter or useReducer dispatch with a new value.
  2. A context it consumes changes — every consumer of a Context.Provider re-renders when the provider's value changes.
  3. Its parent re-renders. This is the big one. When a component renders, it renders all of its children by default — regardless of whether their props changed. The re-render cascades down the tree.

That third point is what generates most of the "too many re-renders" anxiety. A state change near the top of your tree re-renders everything below it. Note what is not on this list: a prop changing does not, by itself, trigger a re-render. Props change because the parent re-rendered. The parent is the cause; the new props are a symptom.

This matters because it tells you where to intervene. You don't stop a child from re-rendering by memoizing a value inside it — the child re-runs because its parent did. You stop it by breaking the cascade with React.memo, and React.memo only helps if the props are referentially stable. Which is finally where useMemo earns its place.

The cost of useMemo / useCallback

Memoization is not free, and pretending otherwise is how the noise accumulates. Every useMemo and useCallback:

  • Allocates. It stores the dependency array and the cached value on the fiber. That's memory, per hook, per component instance.
  • Runs a comparison every render. On every re-render, React walks the dependency array and Object.is-compares each entry against the previous one. That comparison is cheap, but it isn't zero, and it runs whether or not the memo helps.
  • Doesn't skip the render. This is the misconception that drives bad memoization. useMemo does not stop your component re-rendering. The function body still runs top to bottom; useMemo only decides whether to recompute that one value or return the cached one.

And the cache invalidates the moment any dependency changes. If your deps change on most renders — say a value derived from the state that's actually updating — the memo recomputes every time and you've paid the comparison cost on top. You've added overhead and gained nothing.

So memoization is a trade: a little fixed cost every render, in exchange for skipping recomputation (or preserving a reference) when deps are stable. It only pays off when the thing you're skipping is more expensive than the bookkeeping, or when the stable reference unlocks a React.memo downstream.

When memoization helps

Two cases, and they're narrower than people assume.

1. A genuinely expensive computation. If you're sorting ten thousand rows or running a heavy transform on each render, and the inputs don't change every render, useMemo is exactly right:

const sorted = useMemo(
  () => bigList.slice().sort(expensiveComparator),
  [bigList]
);

This only recomputes when bigList changes. The comparison cost is trivial next to a 10k-element sort. Clear win.

2. A stable reference passed to a memoized child or an effect. This is the case that actually matters in large apps, and it only works as a pair with React.memo. Here's the before:

function Parent({ rows }) {
  const [count, setCount] = useState(0);
 
  // New array + new function identity on EVERY render of Parent.
  const visible = rows.filter((r) => r.active);
  const handleSelect = (id) => console.log(id);
 
  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <ExpensiveList items={visible} onSelect={handleSelect} />
    </>
  );
}
 
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
  // heavy render
});

ExpensiveList is wrapped in React.memo, so it's supposed to skip re-rendering when its props are unchanged. But every time you click the button, count changes, Parent re-renders, and visible and handleSelect are both created fresh — new array identity, new function identity. React.memo does a shallow prop compare, sees different references, and re-renders the expensive list anyway. The memo is doing nothing.

The after — memoize the props so their identity is stable across Parent renders:

function Parent({ rows }) {
  const [count, setCount] = useState(0);
 
  const visible = useMemo(() => rows.filter((r) => r.active), [rows]);
  const handleSelect = useCallback((id) => console.log(id), []);
 
  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <ExpensiveList items={visible} onSelect={handleSelect} />
    </>
  );
}

Now clicking the button still re-renders Parent, but visible and handleSelect keep the same identity (their deps didn't change). React.memo's shallow compare passes, and ExpensiveList skips its render entirely. The memoization paid off — but only because there was a React.memo boundary for it to feed. useMemo and useCallback are nearly useless on a child that re-renders anyway.

When it hurts, or is just noise

The mirror image of the above:

  • Memoizing cheap values. useMemo(() => a + b, [a, b]) costs more than a + b. You've added a dependency array, an allocation, and a comparison to save an addition. Just write a + b.
  • Memoizing props of a non-memoized child. If the child isn't wrapped in React.memo, it re-renders whenever the parent does, period. Stabilizing its props changes nothing about whether it renders — you've paid for a guarantee no one is checking.
  • Over-memoizing leaf components. A small component that renders some text and a couple of divs renders in microseconds. Wrapping it in React.memo adds machinery to dodge a cost that was never there.

Memoization without a downstream consumer of the stability is pure overhead — a stable reference only matters if something compares it.

Measure, don't guess

Here's the rule I hold the line on: no memoization goes in without a measurement showing it helped. Guessing is how the cargo cult starts.

  • React DevTools Profiler. Record an interaction, and it shows you every component that rendered, why it rendered, and how long each took. This is the primary tool. If a component isn't showing up as slow, memoizing inside it is solving a problem you don't have.
  • "Highlight updates when components render." A toggle in the DevTools settings that flashes a colored border around every component as it re-renders. Turn it on, click around, and you see the cascade — which subtrees light up on every keystroke. It's the fastest way to spot an over-rendering region without reading a flamegraph.
  • why-did-you-render. A dev-only library that logs, for a given component, exactly which prop or state change caused each re-render — and flags re-renders where the props were deeply equal but referentially different (the classic "this React.memo isn't working because of an unstable prop" case).

Profile first. Find the component that's actually slow or actually re-rendering on a hot path. Then apply the narrowest fix — usually a React.memo boundary plus stabilizing exactly the props that feed it.

Rules of thumb from shipping large apps

  • Re-render ≠ DOM update. Confirm the cost is real before optimizing. Often the render is fine and nothing commits.
  • The cascade is the cause. A child re-renders because its parent did. Fix it at the boundary with React.memo, not by memoizing values inside the child.
  • useMemo/useCallback are for stability and expensive math — nothing else. A stable reference is only worth paying for when something downstream (a React.memo child, an effect's dep array) actually compares it.
  • Default to not memoizing. It's easier to add a measured useMemo to a proven hot path than to untangle a tree of speculative ones. Let the profiler tell you where to spend.

Memoization is a precision tool. On the one expensive list in your app, it's the difference between smooth and janky. Sprinkled everywhere "to be safe," it's overhead with a reassuring name — and a codebase nobody can reason about.