There's a phase every React developer goes through where they hear about useMemo, useCallback, and React.memo, decide they're "performance hooks", and start sprinkling them everywhere. The Profiler flame chart looks the same. The bug ticket gets closed. The codebase gets a little harder to read. Everyone moves on.
This article is the version of memoisation I wish someone had explained to me in year one: what each tool actually does, what it costs, and the small list of times it's worth reaching for.
What They Actually Do
All three are about reference identity — making sure the same value gets the same identity across renders, so React (or your code) can short-circuit work.
useMemo caches a value between renders, recomputing only when its dependencies change.
const sortedProducts = useMemo(
() => [...products].sort((a, b) => a.price - b.price),
[products]
);
Without useMemo, the sort runs every render. With it, it runs only when products changes. The cached value is the same array reference across renders that don't trigger a recompute, which matters for downstream memoisation.
useCallback caches a function between renders. It's a special case of useMemo:
const onSelect = useCallback(
(id) => navigate(`/p/${id}`),
[navigate]
);
// roughly equivalent to:
const onSelect = useMemo(
() => (id) => navigate(`/p/${id}`),
[navigate]
);
Same stable reference behaviour. useCallback exists because it's the most common case.
React.memo is for components. It wraps a component so it only re-renders when its props are referentially different from last render:
const ProductCard = React.memo(function ProductCard({ product, onClick }) {
return <article onClick={onClick}>{product.name}</article>;
});
If a parent re-renders without giving ProductCard new prop references, React.memo skips the re-render entirely. Note "referentially" — it's a === check by default, not a deep equality check.
The Cost They Bring
Memoisation isn't free. Each of these tools adds work that runs on every render:
- An extra closure or callback allocation (small but non-zero).
- A dependency-array comparison.
- Bookkeeping for the cached value.
For trivial work, this overhead is more than what you'd save:
// useMemo here is slower than just doing the math
const greeting = useMemo(() => `Hello, ${name}!`, [name]);
// just write:
const greeting = `Hello, ${name}!`;
Performance-wise, useMemo for a string concatenation loses every time. Readability-wise, it adds a line of code with brackets and arrays for no benefit. The default should be "don't memoise" until something tells you otherwise.
When Each One Earns Its Keep
A short list, with examples.
useMemo — for genuinely expensive computations
const filteredSorted = useMemo(() => {
return products
.filter(p => p.category === selectedCategory)
.sort((a, b) => a.price - b.price);
}, [products, selectedCategory]);
If products has 5,000 items and the user types in a search box, you don't want to re-sort and filter on every keystroke unrelated to that. Measure first — if the calculation takes more than ~1ms, useMemo pays. Below that threshold, it's noise.
useMemo — to give a value a stable reference for downstream memoisation
This is the case people miss most often:
function Parent() {
const [count, setCount] = useState(0);
const config = useMemo(
() => ({ color: 'red', size: 'lg' }),
[]
);
return <Child config={config} />;
}
const Child = React.memo(function Child({ config }) {
return <div>{config.color}</div>;
});
Without useMemo, config is a new object every render. React.memo on Child is useless because the prop reference always changes. With useMemo, the reference is stable, and React.memo actually skips re-renders.
If config doesn't depend on anything inside the component, hoisting it to module scope is even better (no useMemo overhead at all):
const STATIC_CONFIG = { color: 'red', size: 'lg' };
function Parent() { return <Child config={STATIC_CONFIG} />; }
useCallback — when the function identity is consumed
useCallback is only useful if something downstream is checking the function's identity. Usually that's:
- A
React.memo'd child receiving the callback as a prop. - A
useEffectthat has the callback in its dependency array. - A custom hook that does either of the above.
const onSelect = useCallback(
(id) => setSelectedId(id),
[]
);
// Used by a memo'd child — useCallback earns its keep
<MemoizedList onSelect={onSelect} />
If the consumer is a non-memo'd child or a regular DOM element (<button onClick={onSelect}>), useCallback saves nothing. The button doesn't care about identity — it just calls the function.
React.memo — for genuinely expensive children that re-render too often
const ChartPanel = React.memo(function ChartPanel({ data }) {
// … heavy SVG / canvas drawing
});
The Profiler is the deciding factor. If ChartPanel shows up in the flame chart at 30ms per render, and it re-renders three times per click, memoising it (and stabilising data) saves ~90ms per interaction. That's a real win. If it shows up at 0.5ms, memoising saves 1.5ms — invisible.
The other rule that goes with it: React.memo only helps if the props are stable. Wrap a component in memo and pass it a fresh object every render and you've added overhead with zero benefit. The two go together.
Common Misuses
A non-exhaustive list of patterns I see during code review:
useMemoaround primitive math.useMemo(() => count * 2, [count]). Multiplying takes nanoseconds. The hook overhead is more.useCallbackfor handlers passed to native elements.<button onClick={useCallback(...)}>— the button doesn't care about identity.React.memoon a leaf component that always renders fresh content. AGreetingthat just returns<p>Hello</p>doesn't need memo.useMemoto "prevent re-renders".useMemodoesn't prevent renders — it caches the value. OnlyReact.memoskips renders.- Memoising a value used in only one place. If the value is used by an inline JSX expression, identity doesn't matter.
- Not memoising the dependencies. Wrapping a callback in
useCallbackwhose dep is itself a fresh array every render: now you're memoising on top of an unstable dep. The cache invalidates every time.
A Worked Example
Here's a pattern from a real codebase. Original, before any memoisation:
function Dashboard({ orders, filters }) {
const filteredOrders = orders.filter(o =>
filters.statuses.includes(o.status)
);
const handleSelect = (id) => console.log(id);
return (
<>
<Toolbar onAction={handleSelect} />
<OrderList orders={filteredOrders} onSelect={handleSelect} />
</>
);
}
It works. The Profiler shows OrderList re-renders on every Dashboard render, even when nothing relevant changes, because filteredOrders and handleSelect are fresh references. If OrderList is heavy (lots of rows), this is wasted work.
The "cargo cult" version, which doesn't help:
const filteredOrders = useMemo(
() => orders.filter(o => filters.statuses.includes(o.status)),
[orders, filters] // filters is also a new ref every render
);
If filters is recreated by the parent, filteredOrders recomputes anyway, and OrderList still gets a new orders prop. Memoisation is broken at the top.
The actual fix:
const filteredOrders = useMemo(
() => orders.filter(o => filters.statuses.includes(o.status)),
[orders, filters.statuses] // depend on the stable inner field
);
const handleSelect = useCallback((id) => console.log(id), []);
const MemoOrderList = React.memo(OrderList);
Three coordinated changes: stable inputs to useMemo, stable callback identity, memoised consumer. Now the chain holds and OrderList skips re-renders unless its actual data changes. Dropping any of the three breaks the chain.
A Decision Tree, In One Place
When you're tempted to add a memoisation tool:
- Is there a measured performance problem? No → don't add it.
- Is the hot spot an expensive computation? Yes →
useMemo. - Is the hot spot a heavy child that re-renders often? Yes →
React.memoon the child and stabilise its props. - Are you stabilising a function for a memo'd child or a deps array? Yes →
useCallback. - Was the value already a stable reference (string, number, hoisted constant)? Yes → don't add anything.
The honest summary: 90% of useMemos and useCallbacks in real codebases don't measurably help. The 10% that do, do so because someone profiled, found a hot spot, and reached for the right tool. Be in the 10%.



