memo, useMemo, useCallback
memo, useMemo and useCallback only pay off as one system: memo skips a child whose props are shallow-equal, and useMemo/useCallback supply the stable object/function identities that let that equality hold — any link missing makes the whole chain a no-op.
You profiled a slow list, wrapped the row in React.memo, and nothing changed. So you added useCallback to the click handler — still nothing. You sprinkled useMemo over a derived array — the flamegraph didn’t budge. Each tool is doing exactly what its docs say, and the page is still re-rendering everything. The tools aren’t broken. You used them as three independent spells instead of one mechanism.
memo, useMemo, and useCallback are not interchangeable performance dust. They are three parts of a single contract: memo decides whether to skip a child by comparing props by reference; useMemo and useCallback are how you keep those references stable across renders. Take any one out of the loop and the other two do nothing. This lesson is about wiring the whole loop, and about the failure modes where one missing link silently voids the cache.
After this lesson you can state precisely what each tool does — React.memo skips a re-render when next props are shallow-equal to previous, useMemo caches a computed value’s identity, useCallback caches a function’s identity — and explain why a memo’d child only skips when its object and function props are stable. You can diagnose the two signature failures: a useCallback/useMemo whose consumer isn’t wrapped in memo (zero effect), and a stale or unstable dependency array that quietly defeats the cache. And you can decide when memoizing is worth its cost versus when it’s noise.
React.memo skips a child’s re-render only when its next props are shallow-equal to the previous ones. By default a child re-renders whenever its parent does, regardless of props. memo changes that: before re-rendering, React shallow-compares each prop with Object.is. Primitives compare by value, so a number or string prop that didn’t change passes. But objects, arrays, and functions compare by reference — a fresh literal every render is never Object.is-equal to last render’s, so the comparison fails and the child renders anyway.
const Row = React.memo(function Row({ user, onSelect }: {
user: User;
onSelect: (id: string) => void;
}) {
return <li onClick={() => onSelect(user.id)}>{user.name}</li>;
});
// memo's promise: skip re-render IF user AND onSelect are reference-stableSo memo is necessary but not sufficient. It only buys you a skip when every non-primitive prop arrives with a stable identity. The next two tools are how you supply that stability.
useMemo caches the identity of a computed value; useCallback caches the identity of a function — both keyed by a dependency array. They are the supply side of the contract. useMemo(fn, deps) runs fn and returns the same reference until a dep changes. useCallback(fn, deps) returns the same function reference until a dep changes — it’s literally useMemo(() => fn, deps). Their point is almost never raw compute speed; it is referential stability so that a downstream memo (or an effect’s dependency check) sees “same value, skip”.
function UserList({ users, onSelect }: Props) {
// stable identity → a memo'd <Row onSelect> can skip
const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);
// stable identity → a memo'd consumer of `visible` can skip
const visible = useMemo(() => users.filter((u) => u.active), [users]);
return visible.map((u) => <Row key={u.id} user={u} onSelect={handleSelect} />);
}If you write const handleSelect = (id) => onSelect(id) instead, you mint a new function every render, and any memo downstream comparing that prop fails every time. The cache is only as stable as the references you feed it.
The trio is one system: a memo’d child needs stable props, and stable props are useless without a memo’d child. Picture the chain. useCallback/useMemo produce stable references → those references are passed as props → React.memo shallow-compares them and, finding them equal, skips. Remove memo and the child re-renders regardless, so the stable identity bought nothing. Remove the useCallback/useMemo and the prop changes identity every render, so memo’s comparison always fails and it never skips. Every link must be present or the whole chain is a no-op.
// ❌ broken chain: stable handler, but Row is NOT memo'd → renders anyway
const handleSelect = useCallback(fn, [fn]);
return <PlainRow onSelect={handleSelect} />; // useCallback does literally nothing here
// ❌ broken chain: Row IS memo'd, but handler is a fresh literal → never skips
return <MemoRow onSelect={(id) => onSelect(id)} />;
// ✅ closed chain: memo'd child + stable prop → Row skips when user/onSelect unchanged
return <MemoRow user={user} onSelect={handleSelect} />;This is the senior reframing: you don’t “add a useCallback”. You either complete a memoization chain or you don’t have one.
The two failure modes: a memoization with no memo’d consumer, and a dependency array that’s wrong. The first is pure waste — a useCallback or useMemo whose result flows only into a plain (un-memo’d) component, an intrinsic element like <button onClick={...}>, or nothing render-relevant at all. It costs an allocation and a deps check every render and skips zero renders. The second is subtler and worse: a dependency array that omits a value the closure reads gives you a stale cached value (a callback that calls last render’s onSelect), while an array containing an unstable value (an inline object, a value that changes every render) invalidates the cache every render, so you pay all the memoization cost and get none of the benefit.
// ❌ stale: handler closes over `query` but deps omit it → fires the old query
const search = useCallback(() => fetchResults(query), []); // bug: missing `query`
// ❌ self-defeating: `options` is a new object each render → useMemo recomputes always
const opts = { sort: "name" };
const sorted = useMemo(() => sortBy(users, opts), [users, opts]); // opts breaks itThe lint rule react-hooks/exhaustive-deps catches the stale case. Nothing catches the un-memo’d-consumer case for you — it’s a reasoning failure, not a syntax one, which is why “memoization is a system” has to be a habit, not a checklist item.
A search box that re-renders a 5,000-row list on every keystroke — fixed by closing the chain. The parent owns the query; typing re-renders the parent, which re-renders every row.
Before — three attempts, none working, because the chain is never complete:
function People({ users, onSelect }: Props) {
const [q, setQ] = useState("");
const visible = users.filter((u) => u.name.includes(q)); // new array each render
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} />
{visible.map((u) => (
// Row is NOT memo'd, and onSelect is a fresh closure → every keystroke
// re-renders all 5,000 rows even though only `q` changed
<Row key={u.id} user={u} onSelect={(id) => onSelect(id)} />
))}
</>
);
}
function Row({ user, onSelect }: RowProps) { /* expensive */ }After — every link present, so typing re-renders only the rows whose visibility actually changed:
const Row = React.memo(function Row({ user, onSelect }: RowProps) {
return <li onClick={() => onSelect(user.id)}>{user.name}</li>;
});
function People({ users, onSelect }: Props) {
const [q, setQ] = useState("");
// stable handler identity (depends only on the parent's onSelect)
const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);
// stable derived array (recomputes only when users or q change)
const visible = useMemo(
() => users.filter((u) => u.name.includes(q)),
[users, q],
);
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} />
{visible.map((u) => (
<Row key={u.id} user={u} onSelect={handleSelect} />
))}
</>
);
}Now Row is memo’d (the decider), handleSelect is stable across keystrokes (useCallback), and visible is stable while users/q hold (useMemo). Each row receives the same user and the same onSelect reference, so memo skips it. Only rows whose user object actually differs re-render. Drop any of the three — un-memo Row, inline the handler, or add an unstable dep to visible — and you’re back to 5,000 wasted renders. The fix wasn’t a tool; it was the closed loop.
▸Why this works
Why doesn’t React just memoize everything automatically? Because the comparison and the cached references aren’t free — every memo adds a shallow prop comparison, every useMemo/useCallback adds an allocation plus a dependency-array check on every render. For a cheap component that re-renders rarely, that bookkeeping costs more than the render it might skip. Memoization is a trade: you spend comparison + retention to buy skipped work, and it only nets out when the skipped work is genuinely expensive or frequent. (The React Compiler aims to insert these automatically where they pay off — but the mental model you’re learning is exactly what it encodes, and what you still need to debug it.)
▸Common mistake
The most common waste is a useCallback wrapped around a handler that’s passed straight to a DOM element — <button onClick={useCallback(...)}>. Intrinsic elements aren’t memo’d components; they don’t skip based on prop identity, so the stable reference buys nothing and the useCallback is pure overhead. useCallback/useMemo only earn their keep when the value crosses into a React.memo child or feeds another hook’s dependency array (a useEffect, another useMemo). If the consumer isn’t one of those, delete the wrapper — the unmemoized version is faster.
A parent wraps a handler in useCallback and passes it to <ChildRow onSelect={handler} />. ChildRow is a plain function component (not wrapped in React.memo). The parent re-renders often. What does the useCallback accomplish for render performance here?
React.memo, useMemo, and useCallback are one system, not three optimizations. memo is the decider: it skips a child’s re-render only when next props are shallow-equal to previous, comparing non-primitives by reference. useMemo and useCallback are the supply: they cache the identity of a value and a function across renders, keyed by a dependency array, so those reference comparisons can succeed. A memo’d child with unstable props never skips; a stable prop fed to an un-memo’d child skips nothing — every link must be present or the chain is a no-op. The two failure modes follow directly: a memoization whose consumer isn’t memo’d (or is an intrinsic element) is pure waste, and a dependency array that’s wrong gives you either a stale cached value or a cache that invalidates every render. So never reach for one of these in isolation — ask whether you’re completing a chain, whether the work being skipped is worth the comparison cost, and whether every dependency is itself stable. Memoization is plumbing: it only moves water when the whole pipe is connected.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.