The Cost of Re-Renders: Memoization That Actually Helps
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.
useMemois for genuinely expensive calculations and stable references — not for string concatenation.useCallbackonly pays off when its result is a dependency ofmemo, an effect, or a hook. Otherwise it's dead weight.React.memoneeds 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.

