useMemo & useCallback: When They Actually Help
Memoization is not free. Learn the two cases where it pays, and stop sprinkling it everywhere.
There is a myth that wrapping everything in useMemo and useCallback makes an app faster. It usually does not. Both hooks add memory, add a dependency array to keep correct, and run comparison logic on every render. They pay off in exactly two situations — outside those, they are noise.
The two cases that justify memoization
- A genuinely expensive calculation. Sorting or filtering thousands of rows, parsing, heavy formatting — work you do not want to repeat on unrelated re-renders.
useMemocaches the result. - A stable reference for a memoized consumer. If you pass an object, array, or function to a
memo-wrapped child or into another hook's dependency array, a fresh reference each render defeats the whole point.useMemo/useCallbackkeep the reference steady.
Where it does not help
// Pointless: the sum is cheap and the value is not passed to a memo child.
const total = useMemo(() => a + b, [a, b]); // just write: const total = a + b;Memoizing a trivial expression costs more than it saves. Reserve these hooks for real cost or real reference-stability needs.
The mental model
useCallback(fn, deps) is exactly useMemo(() => fn, deps) — one caches a function, the other caches any value. Neither is a magic speed switch; they are tools for controlling identity and recomputation. Measure with the React Profiler before reaching for them, and remember the React Compiler is increasingly able to insert this memoization for you.
Example
import { useMemo, useCallback, useState, memo } from 'react';
const Row = memo(function Row({ item, onPick }) {
// Re-renders only when item or onPick change by identity.
return <li onClick={() => onPick(item.id)}>{item.name}</li>;
});
function Directory({ people }) {
const [query, setQuery] = useState('');
const [picked, setPicked] = useState(null);
// (1) Expensive-ish filtering: recompute only when inputs change.
const results = useMemo(
() => people.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
[people, query]
);
// (2) Stable callback so memoized <Row> children don't re-render
// every time `query` changes.
const handlePick = useCallback((id) => setPicked(id), []);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((p) => (
<Row key={p.id} item={p} onPick={handlePick} />
))}
</ul>
<p>Picked: {picked ?? 'none'}</p>
</div>
);
}When to use it
- A memoized child component paired with useCallback on the parent's handler prevents the child from re-rendering during unrelated state changes in the parent — the one justified use case.
- A developer profiles their app and discovers a filter + sort computation over 5000 items takes 40ms on every keystroke, justifying a useMemo wrapping the expensive chain.
- A code review removes useMemo from a simple string concatenation that takes under 0.1ms, reducing complexity without any measurable performance difference.
More examples
useMemo justified on large sort
Justified useMemo: wraps a filter+sort over thousands of items that would measurably slow every keystroke.
import { useState, useMemo } from 'react';
function SortedList({ items }) {
const [query, setQuery] = useState('');
// JUSTIFIED: items is large (thousands of entries)
const filtered = useMemo(
() => items
.filter(i => i.name.includes(query))
.sort((a, b) => a.name.localeCompare(b.name)),
[items, query]
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>{filtered.map(i => <li key={i.id}>{i.name}</li>)}</ul>
</>
);
}useCallback paired with memo child
useCallback is justified here because the stable reference prevents the memo'd child from re-rendering on every count update.
import { useState, useCallback, memo } from 'react';
const ExpensiveChild = memo(({ onAction }) => {
console.log('ExpensiveChild rendered');
return <button onClick={onAction}>Do action</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// JUSTIFIED: onAction is passed to a memo'd child
const onAction = useCallback(() => console.log('action'), []);
return (
<>
<button onClick={() => setCount(c => c + 1)}>+</button>
<ExpensiveChild onAction={onAction} />
</>
);
}When NOT to memoize
Contrasts unnecessary memoization of trivial values and unconstrained handlers with the cleaner, equally fast version.
// UNNECESSARY — these are cheap and add overhead for no gain
function BadMemo({ title, count }) {
// Simple string concat — do not wrap in useMemo
const display = useMemo(() => `${title}: ${count}`, [title, count]);
// Handler not passed to any memo'd child — do not wrap
const handleClick = useCallback(() => setCount(c => c + 1), []);
return <button onClick={handleClick}>{display}</button>;
}
// CORRECT — remove both, simpler and just as fast
function GoodMemo({ title, count }) {
const display = `${title}: ${count}`;
return <button onClick={() => setCount(c => c + 1)}>{display}</button>;
}
Discussion