useCallback & useMemo: When They Help vs Hurt
The Memoization Instinct
You just learned about referential equality, and now you want to wrap everything in useCallback and useMemo. We've all been there. It feels like the safe play. But it's actually the wrong instinct. Memoization is not free, and applying it everywhere can make performance worse, not better.
Let's dissect the actual costs and benefits so you can make informed decisions instead of guessing.
useCallback: Preserving Function Identity
useCallback returns the same function reference between renders, as long as dependencies have not changed.
// Without useCallback: new function every render
function SearchPage() {
const [query, setQuery] = useState('');
const handleSearch = (term) => {
fetchResults(term);
};
return <SearchBar onSearch={handleSearch} />;
}
// With useCallback: same function reference while deps are stable
function SearchPage() {
const [query, setQuery] = useState('');
const handleSearch = useCallback((term) => {
fetchResults(term);
}, []); // No deps — function never changes
return <SearchBar onSearch={handleSearch} />;
}
What useCallback actually does internally:
// Simplified React implementation
function useCallback(callback, deps) {
// On first render: store callback and deps
// On re-render: compare deps via Object.is
// If same: return previously stored callback
// If different: store new callback and deps, return new callback
return useMemo(() => callback, deps);
}
useCallback(fn, deps) is literally useMemo(() => fn, deps). And here's what trips people up: it does not prevent function creation — the fn argument is still a new closure allocated on every render. What it prevents is that new closure from being returned. React keeps the old reference and discards the new one.
useMemo: Caching Computed Values
useMemo caches the result of a computation and only recomputes when dependencies change.
function ProductList({ products, filter }) {
// Without useMemo: filters on every render
const filtered = products.filter(p => p.category === filter);
// With useMemo: only filters when products or filter changes
const filtered = useMemo(
() => products.filter(p => p.category === filter),
[products, filter]
);
return filtered.map(p => <ProductCard key={p.id} product={p} />);
}
The Hidden Costs
This is the part nobody talks about. Memoization is not free. Every useCallback and useMemo call has measurable overhead.
Cost 1: Closure Allocation
The function you pass to useCallback or useMemo is a new closure every render. Closures capture their surrounding scope. Wrapping a function in useCallback means you allocate two functions per render instead of one: the inner function (your callback) and the wrapper that useCallback uses internally.
Cost 2: Dependency Array Comparison
React must compare every element in the dependency array on every render using Object.is. For useCallback(() => {}, [a, b, c, d, e]), React runs 5 comparisons per render. This is cheap per comparison, but it compounds across dozens of hooks.
Cost 3: Memory
Memoized values and their dependency arrays are stored on the fiber. They persist between renders. If you memoize a large array, React keeps both the current and previous versions in memory (for comparison). useMemo trades CPU for memory.
Cost 4: Cognitive Load
Every useCallback and useMemo is a maintenance point. Dependencies must be correct and exhaustive. Stale closures from wrong deps are hard to debug. The code becomes harder to read.
// Before: clear, simple
function handleClick(id) {
setSelected(id);
}
// After: harder to read, same runtime behavior unless handleClick
// is passed to a React.memo child
const handleClick = useCallback((id) => {
setSelected(id);
}, []);
When useCallback Actually Helps
So when does it actually pay off? There are exactly three scenarios.
1. Passing to React.memo'd Children
This is the canonical use case. useCallback only matters when the consumer checks referential equality.
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
return items.map(item => (
<div key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</div>
));
});
function Parent() {
const [filter, setFilter] = useState('');
const items = useItems();
// Without useCallback: ExpensiveList re-renders on every keystroke
// because onSelect is a new function reference each time
const handleSelect = useCallback((id) => {
selectItem(id);
}, []);
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<ExpensiveList items={items} onSelect={handleSelect} />
</div>
);
}
2. Dependencies of useEffect
If a function is used in a useEffect dependency array, it needs a stable reference to avoid infinite re-runs.
function DataFetcher({ userId }) {
const fetchUser = useCallback(async () => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
}, [userId]);
useEffect(() => {
fetchUser().then(setUser);
}, [fetchUser]); // Stable reference — effect only re-runs when userId changes
}
3. Custom Hooks That Accept Callbacks
Hooks that store or compare callbacks benefit from stable references:
function useEventListener(event, handler) {
const savedHandler = useRef(handler);
useEffect(() => {
savedHandler.current = handler;
}, [handler]); // Stable handler = no unnecessary ref updates
useEffect(() => {
const listener = (e) => savedHandler.current(e);
window.addEventListener(event, listener);
return () => window.removeEventListener(event, listener);
}, [event]);
}
When useMemo Actually Helps
1. Expensive Computations
If a computation takes more than ~1ms, memoizing it is worthwhile.
function AnalyticsDashboard({ transactions }) {
// O(n) computation over potentially millions of records
const summary = useMemo(() => {
return {
total: transactions.reduce((sum, t) => sum + t.amount, 0),
average: transactions.reduce((sum, t) => sum + t.amount, 0) / transactions.length,
byCategory: groupBy(transactions, 'category'),
topMerchants: getTopN(transactions, 'merchant', 10),
};
}, [transactions]);
return <Dashboard data={summary} />;
}
2. Stabilizing References for memo'd Children
function Parent({ items }) {
// Without useMemo: new array reference every render breaks child's memo
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
return <MemoizedList items={sortedItems} />;
}
3. Avoiding Unnecessary Effect Triggers
function ChartContainer({ data, config }) {
const chartOptions = useMemo(
() => buildChartOptions(data, config),
[data, config]
);
useEffect(() => {
renderChart(chartOptions); // Only re-renders chart when options actually change
}, [chartOptions]);
}
When They Hurt
Now for the cases where memoization actively makes things worse.
Unstable Dependencies
If dependencies change on every render, memoization runs the comparison AND recomputes — strictly worse than just computing.
function BadExample({ data }) {
// data is a new object reference every render (from parent)
const processed = useMemo(
() => processData(data),
[data] // data reference changes every render → memoization never caches
);
// useMemo runs Object.is(prevData, nextData) — false every time
// Then recomputes processData(data) — same as without useMemo
// Net cost: deps comparison + closure allocation for zero benefit
}
Over-Memoization Chains
When you memoize everything, you create chains of dependencies that are fragile and hard to debug.
function OverMemoized({ items, user }) {
const filteredItems = useMemo(() => items.filter(i => i.active), [items]);
const sortedItems = useMemo(() => [...filteredItems].sort(sortFn), [filteredItems]);
const handleSelect = useCallback((id) => selectForUser(user.id, id), [user.id]);
const handleBulk = useCallback(() => bulkAction(sortedItems), [sortedItems]);
const headerProps = useMemo(
() => ({ onSelect: handleSelect, onBulk: handleBulk, count: sortedItems.length }),
[handleSelect, handleBulk, sortedItems]
);
return <Header {...headerProps} />;
}
Take a step back and look at this. Five memoization hooks forming a dependency graph. If items changes, the entire chain recomputes sequentially. The code is harder to read, harder to maintain, and the cascading recomputations may offer no benefit if Header is not wrapped in React.memo.
A common mistake is memoizing a value that is only used once in the same component's JSX. If the value is not passed to a memo'd child, there is no consumer that benefits from a stable reference. The component itself re-renders regardless — its own JSX consumes the value fresh each time.
// Pointless: label is a string primitive — already referentially stable
const label = useMemo(() => `${count} items`, [count]);
// Pointless: result only used in this component's JSX, no memo'd children
const result = useMemo(() => count * 2, [count]);
return <p>{result}</p>; // This component already re-rendered to reach this lineThe Decision Framework
Should I use useCallback/useMemo?
1. Is the value passed to a React.memo component as a prop?
→ YES: Memoize it (useCallback for functions, useMemo for objects/arrays)
→ NO: Continue to #2
2. Is the value used in a useEffect/useMemo/useCallback dependency array?
→ YES: Consider memoizing to prevent unnecessary effect re-runs
→ NO: Continue to #3
3. Is the computation genuinely expensive (>1ms)?
→ YES: useMemo to avoid recomputing
→ NO: Don't memoize. The overhead exceeds the benefit.
React Compiler: The End of Manual Memoization
Here's the good news: you won't have to make these decisions manually forever. React Compiler analyzes component code at build time and automatically inserts memoization where it is beneficial. It understands data flow, identifies which values need stable references, and generates the optimal useMemo/useCallback calls.
// You write natural, clean code:
function TodoList({ todos, filter }) {
const visible = todos.filter(t => t.status === filter);
const handleToggle = (id) => toggleTodo(id);
return visible.map(t => <TodoItem key={t.id} todo={t} onToggle={handleToggle} />);
}
// React Compiler outputs optimized code with memoization where it matters:
// - visible is memoized because it's derived data
// - handleToggle is memoized because TodoItem may benefit
// - The map result is memoized for the component's own output stability
The compiler makes the right choice for each value automatically — no more "should I memoize this?" decisions. It skips memoization for primitives, cheap computations, and values with unstable deps.
Until the compiler is stable and adopted, the manual decision framework above applies. Understanding the costs and benefits remains important for debugging and for codebases not yet on the compiler.
- 1useCallback does not prevent function creation — it preserves the reference to a previously created function.
- 2useMemo trades CPU time for memory. Only use it when the computation cost exceeds the memoization overhead.
- 3Memoization only benefits consumers that check referential equality: React.memo components and hook dependency arrays.
- 4If dependencies change every render, memoization is strictly worse — it runs the comparison AND recomputes.
- 5Don't memoize primitives (strings, numbers, booleans) — they are always referentially equal by value.
- 6Prefer component restructuring (move state down, lift content up) over memoization chains.
- 7React Compiler will automate memoization decisions, but understanding the costs remains essential for debugging.
Q: You join a codebase where every function and computed value is wrapped in useCallback or useMemo. How would you assess which ones to keep and which to remove?
A strong answer covers: audit each hook's consumers (is the value passed to a React.memo component? Used in an effect dependency array?), measure with React DevTools Profiler to identify which memoizations actually prevent re-renders, check dependency stability (are deps changing every render, making the cache useless?), remove memoization on trivial computations (mapping 3 items, string concatenation), and note that removing unnecessary memoization improves readability and reduces memory usage.