useMemo and useCallback: Referential Stability
What you'll learn
- What
useMemoanduseCallbackreally cache: a value's identity, keyed by its deps - Why
useCallback(fn, deps)is literallyuseMemo(() => fn, deps) - The three places where identity actually matters: memoized children, effect deps, other memos
- When NOT to use these hooks (most places), and what they cost
- A worked example: what actually breaks with and without them
Ask ten developers what useMemo is for and nine will say "performance". Then they wrap every function in useCallback and every object in useMemo, and the app does not get faster. These hooks do something far more specific than "speed": they keep a value's identity stable between renders. Once you see where identity matters, every correct use becomes obvious, and so does every place they are pure noise.
The mechanism: a value slot with a dependency key
Jargon: "referential equality". Comparing two values with
===, asking "are these the same object?" For objects and functions,===compares identity, not content:{a: 1} === {a: 1}isfalse.
Here is the problem. Every render re-runs your component function, so every object, array, or function literal gets a brand-new identity. Usually that is harmless. Sometimes it causes real trouble. useMemo lets you keep the old identity until its inputs actually change:
import { useMemo, useState } from 'react';
function slowFilter(items, query) {
console.log('filter ran');
return items.filter((i) => i.toLowerCase().includes(query.toLowerCase()));
}
const FRUITS = ['Apple', 'Banana', 'Cherry', 'Date'];
export default function Search() {
const [query, setQuery] = useState('');
const [dark, setDark] = useState(false);
const results = useMemo(() => slowFilter(FRUITS, query), [query]);
return (
<div style={{ background: dark ? '#222' : '#fff' }}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter fruit"
/>
<button onClick={() => setDark(!dark)}>Toggle theme</button>
<ul>{results.map((r) => <li key={r}>{r}</li>)}</ul>
</div>
);
}
What happens:
- First render: there is no cached value, so
slowFilterruns ("filter ran" logs) and React stores the result plus the deps[query]on the hook cell. - Click "Toggle theme": the component re-renders,
useMemocompares the new deps to the stored deps withObject.is, samequerystring, so it skipsslowFilterand returns the stored array. No log. - Type a letter:
querychanged, the comparison fails,slowFilterruns again and the new result is stored. - Notice what you really bought: not just skipped work,
resultskeeps the same identity across theme toggles.
Pseudocode model, not real source:
// Conceptually, per hook cell:function useMemo(compute, deps) {const cell = getCurrentHookCell();const same = cell.deps&& deps.length === cell.deps.length&& deps.every((d, i) => Object.is(d, cell.deps[i]));if (!same) {cell.value = compute(); // recompute...cell.deps = deps; // ...and remember the new key}return cell.value; // same identity until deps change}
Jargon: "hook cell". The per-component, per-hook storage slot React keeps between renders. It is how any hook remembers anything: state lives in cells, and so do memo caches.
Jargon: "deps" (dependency array). The list of values React compares with
Object.ison each render to decide whether the cached value is still valid. Any changed dep invalidates the cache.
useCallback is not a separate invention
const onSelect = useCallback((id) => setSelected(id), []);
// means exactly:
const onSelect = useMemo(() => (id) => setSelected(id), []);
That is all it is: "cache this function's identity." The function is still created on every render (you wrote it inline); useCallback simply discards the new one and returns the old one while deps match. It exists as a separate hook only because "cache this function" is such a common need.
Where identity matters #1: props of a memoized child
Jargon: "React.memo". A wrapper telling React: "if this component's props are shallowly equal (
Object.isper prop) to last render's, skip re-rendering it."
Memo is a boundary, and unstable props punch holes in it:
import { memo, useCallback, useState } from 'react';
const ITEMS = [
{ id: 1, label: 'Inbox' },
{ id: 2, label: 'Starred' },
{ id: 3, label: 'Trash' },
];
const Row = memo(function Row({ item, onSelect }) {
console.log('Row rendered:', item.label);
return <li onClick={() => onSelect(item.id)}>{item.label}</li>;
});
export default function Sidebar() {
const [selected, setSelected] = useState(null);
const [count, setCount] = useState(0);
const handleSelect = useCallback((id) => setSelected(id), []);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>
Unrelated counter: {count}
</button>
<p>Selected: {selected ?? 'none'}</p>
<ul>
{ITEMS.map((item) => (
<Row key={item.id} item={item} onSelect={handleSelect} />
))}
</ul>
</div>
);
}
(Note that ITEMS is module-level, so each item keeps a stable identity too.)
What happens (with useCallback):
- Click the counter:
Sidebarre-renders because its state changed. useCallback's deps[]are unchanged → the samehandleSelectfunction identity is returned.- React renders each
<Row>element;memocompares props:itemis the same object,onSelectis the same function → bail out. No row re-renders. The console stays quiet.
Now watch the chain break. Remove useCallback:
const handleSelect = (id) => setSelected(id); // a NEW function every render
- Click the counter →
Sidebarre-renders → a brand-newhandleSelectis created. memocompares props for eachRow:onSelect !== previous onSelect,Object.isfails.- Every row re-renders: "Row rendered: Inbox", "Row rendered: Starred"… on every counter click.
- One unstable prop defeats memo entirely. The chain: parent renders → new identity → memo's comparison fails → child re-renders.
This is the real reason useCallback exists: to protect the memo boundary below you.
Where identity matters #2: effect dependency arrays
Effect deps use the same Object.is comparison, which produces the infamous "effect runs in a loop" bug:
import { useEffect, useState } from 'react';
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const options = { url: `/api/users/${userId}`, method: 'GET' };
useEffect(() => {
fetch(options.url).then((r) => r.json()).then(setUser);
}, [options]); // ⚠️ options is a NEW object every render!
return <pre>{JSON.stringify(user, null, 2)}</pre>;
}
What happens:
- Render:
options = { … }, a fresh object identity. - The effect compares deps:
Object.is(newOptions, oldOptions)isfalse→ the effect re-runs. - The fetch resolves →
setUser→ re-render → another newoptionsobject → the effect re-runs → fetch again → … - The network tab shows an endless stream of requests. Nobody wrote a loop; identity wrote it for you.
Two honest fixes. Fix A, build what you need inside the effect so it stops being a dependency:
import { useEffect, useState } from 'react';
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then(setUser);
}, [userId]); // a primitive dep: stable by value
return <pre>{JSON.stringify(user, null, 2)}</pre>;
}
Fix B, stabilize the object itself: const options = useMemo(() => ({ url: ... }), [userId]);. Fix A is usually better; Fix B is for when the object is genuinely shared between the effect and the JSX.
Where identity matters #3: deps of other memos
Memoized values compose:
const filtered = useMemo(() => items.filter(pred), [items, pred]);
const sorted = useMemo(() => [...filtered].sort(compare), [filtered]);
sorted's cache key includes filtered's identity. If filtered were not memoized, it would be a new array every render and sorted could never hit its cache. Stable identity in, stable cache out.
When NOT to reach for them
- Cheap calculations.
useMemo(() => a + b, [a, b]), the comparison ceremony costs more than the addition. - Primitives. Strings, numbers, and booleans are compared by value anyway; a memoized
42is the same42. Identity is a non-issue. - Props of non-memoized children. A plain child re-renders whenever its parent renders, regardless of prop identity,
useCallbackon its props buys nothing until someone wraps the child inmemo. - By default. Start with nothing memoized; add it where a measured problem or a memo boundary actually exists.
And the cost is real: memory (cells hold the value and the deps), a comparison on every render, and code noise that buries intent. One more reason to write fewer of them by hand: the React Compiler, the performance part of the project, automatically memoizes values and functions during the build. As it matures, hand-written useMemo/useCallback for stability become mostly unnecessary, something you write only to express intent.
Worked example: with and without
A filterable list with a memoized row, a memoized filter, and a stable callback:
import { memo, useCallback, useMemo, useState } from 'react';
const PRODUCTS = [
{ id: 1, name: 'Keyboard', price: 80 },
{ id: 2, name: 'Mouse', price: 40 },
{ id: 3, name: 'Monitor', price: 300 },
{ id: 4, name: 'Webcam', price: 90 },
];
const Row = memo(function Row({ product, onSelect }) {
console.log('Row rendered:', product.name);
return (
<li>
{product.name} (${product.price})
<button onClick={() => onSelect(product.id)}>select</button>
</li>
);
});
export default function ProductList() {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(null);
const visible = useMemo(
() => PRODUCTS.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
[query]
);
const onSelect = useCallback((id) => setSelected(id), []);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter" />
<p>Selected: {selected ?? 'none'}</p>
<ul>
{visible.map((p) => <Row key={p.id} product={p} onSelect={onSelect} />)}
</ul>
</div>
);
}
What happens:
- Typing changes
query→visiblerecomputes → the matching rows render. Selecting a product re-renders the list (theselectedstate changed), butonSelect's identity is stable and theproductobjects are module-level constants, so noRowre-renders. - The chain holds: stable
onSelect+ stableproductobjects +memo(Row)= rows render only when their own data changes.
Now remove all three optimizations, a plain Row function, an inline PRODUCTS.filter(...) in the JSX, an inline onSelect arrow, and replay:
- Typing still filters correctly and feels instant. Four items were never the bottleneck; removing
useMemochanged nothing you can perceive. - Selecting a product now re-renders every row: the parent rendered,
Rowis not memoized, so all rows re-render. (Memo alone would not even help, the inlineonSelectis a new function each time.) - What actually broke: extra
Rowrenders. Not the filter, not correctness. With 4 rows it is invisible; with 4,000 heavy rows it is a janky keystroke.
That is the honest summary: these hooks change how often things render, never what renders. If removing them changes what you see on screen, something else was wrong.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception:
useMemomakes code faster. Reality: it makes identity stable; it only saves time when the computation itself is expensive, and it costs a comparison on every render. - Misconception:
useCallbackstops the function from being created. Reality: the inline function is created every render regardless; the hook just keeps returning the old one while deps match. - Misconception: memoized values are guaranteed never to recompute. Reality: it is a cache, not a contract, React may discard it (for example, to free memory for offscreen content) and recompute when needed. Never build correctness on it.
- Misconception: wrapping everything is harmless. Reality: each wrapper costs memory, comparison time, and readability, and unstable-by-default is fine wherever nothing depends on identity.
- Misconception:
useCallback(fn, [])always sees the latest state. Reality: with empty deps it closes over the first render's values; use updater functions (setX(x => ...)) or list the dep.
Why it works this way
- Identity is a JavaScript fact:
===on objects and functions compares references. React builds memo and deps on that fact instead of deep comparisons, which would be slow and unpredictable. - Hook cells make "remember the last value plus its key" trivial: each hook call gets a slot, compared and refilled in call order.
- Stability is opt-in because most identity churn is harmless; stabilizing everything by default would cost memory and mask real data changes.
- The compiler direction exists because humans are bad at placing these by hand, the rule ("stabilize what crosses a memo boundary") is mechanical enough to automate.
Try it yourself
- In
ProductList, addconsole.count('Row ' + product.name)insideRow. Select products with and withoutuseCallbackononSelectand compare the counts. - Generate 5,000 products in code, remove the
useMemoaroundvisible, throttle your CPU, and type. Then restore it. Feel where the cost actually lives. - Reproduce the
options-loop bug inUserProfile, watch the network tab fill up, then fix it by moving the object inside the effect. - Rewrite
useCallback((id) => setSelected(id), [])asuseMemo(() => (id) => setSelected(id), [])and confirm the behavior is identical.
Recap
useMemocaches a value's identity on a hook cell, keyed by deps compared withObject.is.useCallback(fn, deps)is exactlyuseMemo(() => fn, deps).- The point is referential stability, and it matters in three places: memoized children, effect dep arrays, and other memos' deps.
- A new inline object or function each render is the root of both the "memo defeated" and the "effect loop" bugs.
- Do not memo cheap work, primitives, or props of non-memoized children, you pay memory, comparison, and noise for nothing.
- These hooks change how often things render, never what renders, and the React Compiler may soon write them for you.