useMemo, useCallback & React.memo: When Memoization Actually Pays
A senior engineer's guide to React's three memoization primitives, the referential-equality model underneath them, and why useCallback rarely makes your app faster.
React's memoization trio — useMemo, useCallback, and React.memo — is among the most cargo-culted parts of the API. They all hang off one idea: referential equality. Get that idea right and you know exactly when each one pays off and when it is just noise the next person has to read.
1. Referential equality: the root of everything
React decides whether to re-render and whether props "changed" using Object.is — a shallow, identity-based comparison. For primitives that matches value equality. For objects, arrays, and functions it compares the reference, not the contents. Two structurally identical literals are never equal.
Object.is(1, 1); // true
Object.is('a', 'a'); // true
Object.is({}, {}); // false — different references
Object.is([1], [1]); // false
Object.is(() => {}, () => {}); // false
Every render of a function component re-executes its body. So every object, array, and arrow function written as a literal inside that body is a brand-new reference each render. That is the mechanism behind almost every "why does this re-render / re-run" question.
function Parent() {
// new object, new array, new function every single render:
const style = { color: 'red' };
const items = data.filter(d => d.active);
const onClick = () => doThing();
return <Child style={style} items={items} onClick={onClick} />;
}
If Child is wrapped in React.memo, none of those props will ever look "equal" between renders, so the memo does nothing. This is the trap the three APIs exist to solve — and the trap they create when misused.
2. What each one actually does
They are not interchangeable. Each addresses a different unit of stability.
useMemo(fn, deps)caches a value. It runsfnand returns its result; on later renders it returns the same cached result (same reference) until a dependency changes byObject.is.useCallback(fn, deps)caches a function identity. It is equivalent touseMemo(() => fn, deps)— sugar for memoizing a function value so its reference is stable across renders.React.memo(Component)wraps a component and skips re-rendering it when a parent re-renders, provided its incoming props are shallow-equal to the previous props. It does not cache values or functions; it gates rendering. Note its scope: it only blocks renders caused by the parent. A memoized component still re-renders when its ownuseStateupdates or a context it reads withuseContextchanges.
The crucial connection: React.memo only helps if the props you pass are already referentially stable. useMemo and useCallback are how you make objects/arrays and functions stable enough for that gate to ever close. They are a team. One without the others is usually pointless.
const Child = React.memo(function Child({ items, onClick }) {
return <ul onClick={onClick}>{items.map(renderRow)}</ul>;
});
function Parent({ data }) {
const items = useMemo(() => data.filter(d => d.active), [data]);
const onClick = useCallback(() => doThing(), []);
// Now Child's props are stable; React.memo can actually skip re-renders.
return <Child items={items} onClick={onClick} />;
}
3. When memoization actually pays
There are three legitimate categories. If your use case is not one of these, you are almost certainly adding noise.
(a) Genuinely expensive computation. useMemo earns its keep when the function body is costly relative to a render — sorting/filtering large datasets, parsing, building derived structures, heavy formatting. Here the goal is to avoid recomputing, and referential stability is a bonus.
const sorted = useMemo(
() => rows.slice().sort(expensiveComparator),
[rows]
);
(b) Stable props feeding a memoized child. When a heavy subtree is wrapped in React.memo and you pass it objects/arrays/callbacks, useMemo/useCallback keep those props stable so the memo gate can close. The payoff is skipping the child's render, not the memo itself.
(c) Values used as dependencies of other hooks. This is the case people forget, and it is about correctness, not just speed. If an object or function is a dependency of useEffect, useMemo, or a custom hook, an unstable reference causes the effect to fire on every render — extra fetches, resubscriptions, infinite loops.
// options is a new object each render => effect runs every render
const options = useMemo(() => ({ url, headers }), [url, headers]);
useEffect(() => {
const sub = subscribe(options);
return () => sub.unsubscribe();
}, [options]); // now only re-subscribes when url/headers truly change
Memoizing a function so it can safely live in a dependency array is one of the few times useCallback is unambiguously the right tool.
4. When it is noise — or actively harmful
Memoization is not free. useMemo/useCallback allocate, store deps, and run a comparison on every render. For cheap values that cost can exceed the work you "saved." The honest framing: you trade a little CPU and memory plus a lot of reading complexity for a chance to skip work later.
- Memoizing primitives or trivial computation.
useMemo(() => a + b, [a, b])is slower and harder to read thana + b. There is no reference to stabilize. - Wrapping a callback that is not a dependency and is not passed to a memoized child. Stabilizing a function that nothing compares against accomplishes nothing.
React.memoon components whose props change every render anyway. The shallow compare runs, always fails, and you pay for the comparison plus get zero skipped renders. Children passed viaprops.childrenare a classic silent breaker — JSX children are new elements each render.- Harmful correctness bugs from stale closures. An over-aggressive
useCallback(fn, [])can capture stale state/props, so the "stable" function reads outdated values. The reference is stable; the behavior is wrong.
Worth naming: the React Compiler reached its 1.0 stable release in October 2025. It is a separate build-time tool (not part of React core), compatible with React 17 and up, that inserts memoization automatically — which is precisely why hand-written memoization is increasingly something to justify rather than default to. If the compiler is enabled in your build, most manual useMemo/useCallback becomes redundant.
5. Dependency-array gotchas
The deps array is where memoization quietly goes wrong. The contract: list every reactive value the function reads. Lint with eslint-plugin-react-hooks and do not silence it casually.
- Missing deps => stale values. The memoized value/function closes over old variables and never updates.
- Unstable deps => useless memo. If a dep is itself a fresh object/array each render, the memo recomputes every time — you added cost for nothing. Stabilize the dep first, or depend on its primitive fields.
- Object/array literals in deps.
[{ id }]or a freshly built array defeat the cache unless that value is already memoized upstream. - Do not lie to the linter. Suppressing the exhaustive-deps warning to "fix" a loop hides the real bug; fix the reference instead. For event handlers that should not be reactive, prefer a ref or the
useEffectEvent(Effect Event) pattern over an empty deps array.
// Bad: filters is a new object each render, so the memo never hits
const result = useMemo(() => query(data, filters), [data, filters]);
// Better: depend on the stable primitives the function actually reads
const result = useMemo(
() => query(data, { status, sort }),
[data, status, sort]
);
And remember React may discard a useMemo cache for its own reasons (for example, to free memory). Treat it as a performance hint, never a correctness guarantee — never put effects or required-once logic inside it.
6. The interviewer's favourite: "does useCallback make my app faster?"
The answer they want: usually no, and on its own, almost never. Here is the precise reasoning to give.
useCallback by itself does zero work-saving — it still creates the function (you wrote the arrow literal regardless), and now it also stores it and compares deps. A stable function reference only matters if something downstream uses the reference: a React.memo child whose render you skip, or a hook dependency you keep stable. If the consumer is not memoized, you have added overhead and bought nothing.
So the senior answer is conditional: useCallback speeds things up only when (1) the callback is passed to a memoized child or used as a dependency, and (2) keeping that reference stable lets React skip meaningful work. Absent those, it is a micro-pessimization dressed up as an optimization. The same logic applies to useMemo for non-expensive values.
Rules of thumb
- Default to not memoizing. Write the plain code, measure with the Profiler, and memoize the hot path you can actually see.
- Memoize for one of three reasons: expensive compute, stable props into a
React.memochild, or a stable reference for a hook dependency. No reason, no memo. React.memoonly helps if every prop is already referentially stable — includingchildren— and only blocks renders driven by the parent, not by the component's own state or context. One unstable prop defeats the whole gate.- Trust the exhaustive-deps lint. Stale closures from wrong deps are correctness bugs, not style nits.
useCallbackalone does not make apps faster — and if the React Compiler (1.0 stable since October 2025) is enabled, most of this is handled for you. Manual memoization is something to justify, not a default.