Three tools that do the same job — skip work when nothing relevant changed — at three different levels. They are also the three most misused features in React, because they look like free performance and are not.
| Caches | Skips | |
|---|---|---|
useMemo | a computed value | recalculating it |
useCallback | a function | recreating it |
memo | a component's output | re-rendering it |
All three compare dependencies with Object.is — reference equality. Understanding
that one fact explains every case where they silently do nothing.
Start here: they are not free
Each one costs a comparison on every render, plus memory to hold the cached value, plus a line of code and a dependency array that can go wrong. Wrapping everything makes an app slower and harder to read.
The default is no memoisation. Add it when you have a reason.
useMemo
Caches the result of a calculation between renders:
/*
* REACT CONCEPT: useMemo
* Recompute the filtered list only when the filter or the data changes — not on every re-render
* caused by opening the modal or the cart drawer.
*/
const visibleProducts = useMemo(() => {
if (activeFilter === 'ALL') return products;
return products.filter((product) => product.type === activeFilter);
}, [activeFilter, products]);Three good reasons to reach for it:
1. The calculation is genuinely expensive
Sorting or filtering thousands of rows, parsing something large, a heavy reduce. Fourteen pizzas
is not expensive — that example is here because it feeds a memo'd child, which is reason
three.
2. The result is a dependency of something else
This is the one people miss. An object or array created during render is a new reference every time, so any effect or memo depending on it re-runs every render:
// `options` is a new object every render → the effect runs every render → probably a loop.
const options = { page, size: 20 };
useEffect(() => { void load(options); }, [options]);
// Stable across renders unless `page` changes.
const options = useMemo(() => ({ page, size: 20 }), [page]);3. It is a context value
A context value is compared by reference, so an object literal in the provider's JSX re-renders
every consumer on every render of the provider. This is the single most valuable place to use
useMemo:
const value = useMemo<CartContextValue>(
() => ({
items: state.items,
orderType: state.orderType,
totals: calculateTotals(state.items, state.orderType),
addItem,
removeItem,
setQuantity,
setOrderType,
clear,
hydrated,
}),
[state.items, state.orderType, addItem, removeItem, setQuantity, setOrderType, clear, hydrated],
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;Note that the totals are derived here rather than stored in state — no
useState, no effect keeping them in sync. That is the important habit;
useMemo is just what makes deriving cheap.
useCallback
useCallback(fn, deps) is useMemo(() => fn, deps). It caches a function
rather than a value, and there is exactly one reason to use it: something downstream compares
that function by reference.
/*
* REACT CONCEPT: useCallback
* ProductCard is wrapped in React.memo, which compares props by reference. Passing an inline
* arrow here would create a brand-new function on every render, so every card would see a
* "changed" prop and re-render — memo would do nothing but waste a comparison.
*/
const handleSelect = useCallback((product: Product) => {
setSelectedProduct(product);
}, []);Remove that useCallback and the memo on ProductCard stops
working entirely. They are a pair; neither is useful alone.
The other case is a function inside a context value, for the same reason:
const removeItem = useCallback((lineId: string) => {
dispatch({ type: 'REMOVE_ITEM', payload: { lineId } });
}, []);
const setQuantity = useCallback((lineId: string, quantity: number) => {
dispatch({ type: 'SET_QUANTITY', payload: { lineId, quantity } });
}, []);Those empty dependency arrays are correct, not lazy: the functions read nothing but
dispatch, which React guarantees never changes. That stability is a quiet advantage of
useReducer in a provider — useState setters are
stable too, but derived values built from state are not.
Wrapping a handler that is passed to a plain DOM element does nothing.
<button onClick={…}> does not compare anything. This is wasted:
// Pointless. Nothing downstream cares about this function's identity.
const handleClick = useCallback(() => setOpen(true), []);
return <button onClick={handleClick}>Open</button>;memo
Wraps a component so React skips re-rendering it when its props are shallowly equal to last time:
/*
* REACT CONCEPT: React.memo
*
* memo skips re-rendering a component when its props are unchanged (compared shallowly).
*
* It matters here because the menu renders 14 of these. Without memo, opening the cart drawer —
* which changes state in a PARENT — would re-render all 14 cards even though not one of their
* props changed.
*
* memo only works if the props are referentially stable. That is exactly why `onSelect` is
* wrapped in useCallback by the parent: an inline arrow function would be a new object on every
* render and memo would never hit.
*
* Do not reach for memo by default. It costs a comparison on every render and is only worth it
* for components that are numerous, expensive, or both.
*/
export const ProductCard = memo(function ProductCard({ product, onSelect }: Props) {
const cheapest = Math.min(...product.sizes.map((s) => s.price));
const isPizza = product.type === 'PIZZA';
return (
<Card className="product-card">
{/* … */}
</Card>
);
});Note the named function inside memo(…). An anonymous arrow works but shows up as
Anonymous in React DevTools and in stack traces, which you will regret.
The three ways memo silently does nothing
All three are the same mistake — a prop that is a new reference every render:
// 1. An inline arrow.
<ProductCard product={product} onSelect={(p) => setSelected(p)} />
// 2. An object literal.
<ProductCard product={product} options={{ compact: true }} />
// 3. An array or JSX built during render.
<ProductCard product={product} badges={product.tags.map(toBadge)} />In each case the comparison fails every time, so memo does nothing but add a
comparison. Fix the props first; the memo is the last step, not the first.
When memo is worth it
Numerous, expensive, or both — and re-rendering for reasons that have nothing to do with them.
ProductCard qualifies on the first and third: fourteen of them, and opening the cart
drawer changes state in a shared ancestor.
A component that renders once, or whose props change whenever its parent does, gains nothing.
Composition is often the better fix
Before reaching for memo, check whether the state can move down instead. A parent
re-rendering its whole tree because of one piece of local state is a structural problem, and moving
that state into the component that uses it — or wrapping the expensive part so it is passed as
children rather than re-created — solves it with no memoisation at all.
Dependency arrays
Same rules as useEffect. List every reactive value the
callback reads, and let the react-hooks/exhaustive-deps rule check you.
// Missing dependency: the memo keeps the FIRST filter's results forever.
const visible = useMemo(() => products.filter((p) => p.type === activeFilter), [products]);
// Correct.
const visible = useMemo(
() => products.filter((p) => p.type === activeFilter),
[products, activeFilter],
);Under-listing produces stale values. Over-listing — or listing something recreated every render — means the memo never hits, which is merely wasteful. Neither is what you wanted.
The React Compiler
React ships an optional compiler that memoises components and values automatically, by
understanding what actually depends on what. Where it is enabled, hand-written
useMemo/useCallback/memo largely stop being necessary.
Two reasons to still learn this:
- Most existing codebases are full of it, and you have to be able to read them.
- The compiler relies on your components being pure and your state being immutable. Understanding why reference equality matters is exactly what those rules are about.
Measure first
Everything above is an optimisation, and optimisations applied without measurement usually cost more than they save. Open the React DevTools Profiler, record the interaction that feels slow, and look at what actually re-rendered and how long it took. Then fix that.
In practice the biggest wins are usually not memoisation at all: a list that should be paginated, an image that should be smaller, a bundle that should be split. Which is the next post.