DevgainsDevgainsDevgains
All articles

The Cost of Re-Renders: Memoization That Actually Helps

·5 min read

There's a ritual in React codebases: wrap everything in useMemo, every callback in useCallback, every component in React.memo, and call it "optimized." Most of the time this does nothing measurable except make the code harder to read and slightly slower, because memoization isn't free — it costs memory and a comparison on every render.

Memoization is a real tool. But it's a scalpel, not a coat of paint. To use it well you need to understand what a re-render actually costs, and when skipping one is worth the bookkeeping. Let's get specific.

What a re-render actually does

When a component re-renders, React calls your function again, builds a new element tree, and diffs it against the previous one to decide what DOM to change. The DOM update is usually the expensive part — but most re-renders produce identical output, so the diff finds nothing to do and the cost is just running your JS.

That distinction matters. A component re-rendering 100 times a second is fine if its render function is cheap and produces the same tree. It's a problem when the render function does real work: filtering a big array, formatting thousands of dates, building a chart's data series. The React docs on render and commit lay out the phases. The rule of thumb: re-renders are cheap; expensive renders are not. Memoization only helps the second kind.

useMemo: cache expensive computation

useMemo caches the result of a calculation between renders, recomputing only when its dependencies change. It earns its keep when the calculation is genuinely expensive:

// Worth memoizing: filtering and sorting 10k rows runs only when inputs change.
const visibleRows = useMemo(
  () => rows.filter(matchesQuery).sort(byDate),
  [rows, query]
);
 
// Not worth it: this runs in nanoseconds; the memo bookkeeping costs more.
const label = useMemo(() => `${count} items`, [count]);

The second case is the trap. Wrapping trivial work in useMemo adds a dependency-array comparison and a cache slot to save you a string concatenation. You've made it slower and harder to read. React's own guidance says to reach for it when the calculation is noticeably slow or when you need a stable reference — not by default.

useCallback and referential identity

useCallback isn't about caching computation — it's about keeping a function's identity stable. This only matters when that identity is a dependency of something else: a React.memo child, a useEffect dependency array, or a custom hook.

// Stable identity so the memoized <List> doesn't re-render on every parent render.
const handleSelect = useCallback((id) => setSelected(id), []);
return <MemoizedList onSelect={handleSelect} />;

If <MemoizedList> is not wrapped in memo, the useCallback does nothing useful — the list re-renders anyway. This is the most common wasted memoization I see: useCallback everywhere, passed to ordinary components that don't compare props. The useCallback docs are explicit that it only pays off in combination with memo or as a stable dependency.

useCallback and useMemo are caches, and caches have a cost: the dependency array is compared on every render, and the cached value occupies memory until the component unmounts. If the thing you're caching is cheaper than the comparison, you've made a net loss. Profile before you assume.

React.memo: skip the subtree

React.memo wraps a component so it skips re-rendering when its props are shallow-equal to last time. This is the one that can actually save expensive renders — but only if its props are stable. Pass it a fresh object or inline arrow and the shallow comparison fails every time, so it re-renders anyway while paying for the comparison:

const Row = React.memo(function Row({ item, onClick }) {
  return <li onClick={onClick}>{item.name}</li>;
});
 
// Defeats memo: new object + new function each render.
<Row item={{ name }} onClick={() => pick(name)} />
 
// Works: stable references.
<Row item={item} onClick={handlePick} />

memo shines when an expensive component sits inside a frequently-rendering parent and its props rarely change. A live-updating dashboard header that re-renders every second shouldn't drag a heavy chart with it — wrap the chart in memo with stable props and it sits still.

The compiler changes the calculus

It's worth saying out loud: the React Compiler exists specifically to make manual memoization mostly unnecessary. It analyzes your components and inserts the equivalent of useMemo/useCallback/memo automatically, based on what actually depends on what. If you're on a version that supports it, the right move is often to delete hand-written memoization and let the compiler do it more precisely than a human sprinkling hooks by reflex.

Until then, the discipline is the same one good engineers always apply: measure, find the component that's actually slow, and apply the narrowest fix. Open the React Profiler, record an interaction, and look for components with high "self" render time or surprising re-render counts. That tells you where a memo or useMemo will pay off — and, more importantly, where it won't.

Takeaways

  • Re-renders are cheap; expensive renders are not. Memoization only helps when the render does real work or feeds a memoized boundary.
  • useMemo is for genuinely expensive calculations and stable references — not for string concatenation.
  • useCallback only pays off when its result is a dependency of memo, an effect, or a hook. Otherwise it's dead weight.
  • React.memo needs stable props to do anything; inline objects and arrows quietly defeat it.
  • Profile first. Find the slow component, fix that one. The React Compiler may soon make most hand-memoization obsolete anyway.
5 min read

Read next