Performance: useMemo, useCallback, and React.memo
All three memoization tools solve the exact same underlying problem — avoid redoing work that didn't need to be redone — but they apply to three different kinds of "work," and reaching for them before actually measuring a real problem is itself a common, real mistake.
4 min read
The shared idea: skip recomputing something if its inputs didn't change
function ExpensiveList({ items }) {
const sorted = items.slice().sort((a, b) => a.value - b.value); // re-sorts on EVERY render,
return <ul>{sorted.map((i) => <li key={i.id}>{i.name}</li>)}</ul>; // even if items didn't change
}Every one of these three tools answers the same underlying question — "did the inputs to this computation actually change since last time, or can the previous result just be reused?" — applied to three different things: a computed value (useMemo), a function (useCallback), and a whole component's render output (React.memo). Understanding the shared idea first is what makes the three separate APIs feel like one concept instead of three things to memorize independently.
useMemo: caching a computed value
function ExpensiveList({ items }) {
const sorted = useMemo(
() => items.slice().sort((a, b) => a.value - b.value),
[items] // only re-sort when items itself actually changes
);
return <ul>{sorted.map((i) => <li key={i.id}>{i.name}</li>)}</ul>;
}useMemo(computeFn, deps) runs computeFn and caches its result — on a later render, if every value in deps is === to what it was last time (the exact same comparison the dependency-array lesson covered for useEffect), React skips re-running computeFn entirely and just returns the cached value from before. This is worth reaching for specifically when the computation itself is genuinely expensive (sorting/filtering a large array, a heavy calculation) — for a cheap computation, the overhead of useMemo's own bookkeeping can easily cost more than just recomputing the value plainly would have.
useCallback: caching a function reference itself
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("clicked");
}, []); // this exact function reference stays stable across re-renders
return <ExpensiveChild onClick={handleClick} />;
}Every time a component re-renders, any function defined inside it is a brand-new function object — even an identical-looking () => console.log("clicked") is a genuinely different reference on every render, the same "new object every render" issue the dependency-array lesson covered for object literals, just applied to functions specifically. useCallback(fn, deps) returns the same function reference across renders as long as deps haven't changed, which matters specifically when that function is passed as a prop to a component wrapped in React.memo (below) — without a stable reference, React.memo's whole optimization is defeated, since the function prop looks "different" every single render even when nothing about it actually changed.
React.memo: skipping a whole component's re-render if its props are unchanged
const ExpensiveChild = React.memo(function ExpensiveChild({ onClick, data }) {
console.log("ExpensiveChild rendered");
return <button onClick={onClick}>{data.label}</button>;
});Recall from the render-cycle lesson: a child re-renders whenever its parent does, by default, regardless of whether its own props actually changed. React.memo wraps a component and changes that default specifically for it — React compares the new props against the previous ones (shallow === comparison per prop, same as useMemo/useCallback's dependency comparison), and skips re-rendering ExpensiveChild entirely if every prop is unchanged. This is exactly why handleClick above needs useCallback to actually work as intended — if Parent passes a fresh onClick function reference every render, React.memo sees a "changed" prop every time and re-renders ExpensiveChild anyway, making the memo wrapper pointless.
Why reaching for these before measuring is a real, common mistake
The render-cycle lesson's own point, restated: re-rendering itself
is comparatively cheap. The expensive part is the actual DOM commit,
and reconciliation already avoids unnecessary DOM writes even when
a component DOES re-render.
Memoization has real costs too: extra memory (caching results),
extra comparison work every render (checking whether deps changed),
and genuine code complexity (a dependency array to keep honestly
correct, exactly like useEffect's).
All three tools trade one cost (recomputing/re-rendering) for a different cost (caching + comparing on every render) — and for a genuinely cheap computation or a component that rarely re-renders anyway, that trade is a net loss, not a win: real memory and complexity spent to "optimize" something that was never actually slow. The correct workflow is: build the feature plainly first, then use React's DevTools Profiler (or just genuine, felt performance issues) to find an actual, measured re-render or computation that's genuinely too slow — and only then reach for the specific tool that fits the specific bottleneck found, rather than wrapping everything in useMemo/useCallback/React.memo reflexively from the start.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What do useMemo, useCallback, and React.memo all share as their underlying idea?
2. Why does React.memo on a child component often fail to prevent re-renders if the parent passes a callback function as a prop?
3. Why is reaching for useMemo/useCallback/React.memo before measuring an actual performance problem considered a real mistake?