useMemo and useCallback in React: When You Actually Need Them
A practical guide to React's useMemo and useCallback hooks, covering referential equality, when memoization helps performance, when it backfires, and how to profile first.
What you'll learn
- ✓The mental model behind useMemo and useCallback
- ✓How referential equality drives unnecessary re-renders
- ✓When memoization actually improves performance
- ✓Common cases where memoization makes things worse
- ✓How to use the React Profiler before reaching for hooks
Prerequisites
- •Familiarity with React hooks (useState, useEffect)
- •Understanding of props and state
useMemo and useCallback are two of the most misunderstood hooks in React. Developers often sprinkle them everywhere in the hope of making components faster, only to add complexity without measurable benefit. This article walks through what these hooks actually do, why they exist, and how to decide whether you need them.
The Mental Model
Both hooks exist for one reason: to keep a value (or function) referentially stable across renders. When a component re-renders, every object, array, and function declared inside its body is created fresh. That means {a: 1} !== {a: 1} from one render to the next, even if the contents look identical.
useMemo caches the result of a computation. useCallback caches a function definition. Both take a dependency array, identical in concept to useEffect.
const expensiveValue = useMemo(() => computeSomething(a, b), [a, b]);
const stableHandler = useCallback(() => doThing(a), [a]);
If the dependencies do not change between renders, React returns the previously cached value. If they do change, React recomputes.
Importantly, useCallback(fn, deps) is just sugar for useMemo(() => fn, deps).
When Memoization Actually Helps
There are three cases where these hooks pull their weight.
1. Skipping Expensive Computations
If you have a CPU-heavy calculation that runs on every render, wrapping it in useMemo prevents redoing the work when inputs have not changed.
function ReportView({ rows }: { rows: Row[] }) {
const summary = useMemo(() => {
return rows.reduce((acc, r) => acc + r.amount, 0);
}, [rows]);
return <div>Total: {summary}</div>;
}
For a reduce over a few hundred rows, this is overkill. For sorting thousands of items or running a complex aggregation, it matters.
2. Preserving Referential Equality for Memoized Children
When a child is wrapped in React.memo, it skips re-renders if its props are referentially equal. If you pass a fresh object or function on every render, the memoization breaks.
const Row = React.memo(function Row({ onSelect, item }: Props) {
return <li onClick={onSelect}>{item.name}</li>;
});
function List({ items }: { items: Item[] }) {
const handleSelect = useCallback((id: string) => {
console.log('selected', id);
}, []);
return (
<ul>
{items.map((i) => (
<Row key={i.id} item={i} onSelect={() => handleSelect(i.id)} />
))}
</ul>
);
}
Note the subtle trap: onSelect={() => handleSelect(i.id)} still creates a new function each render. To truly benefit, the handler must be passed as is.
3. Stable Dependencies for Other Hooks
If a value is used in a useEffect dependency array, an unstable reference causes the effect to fire every render. useMemo and useCallback are useful tools to break that cycle.
const config = useMemo(() => ({ url, retries }), [url, retries]);
useEffect(() => {
fetchWith(config);
}, [config]);
When Memoization Hurts
Memoization is not free. Every call to useMemo and useCallback allocates the dependency array, compares it with the previous one, and stores the result. For trivial values, this overhead is greater than just creating the value fresh.
// Don't bother
const doubled = useMemo(() => count * 2, [count]);
const onClick = useCallback(() => setCount(count + 1), [count]);
There is no measurable improvement from memoizing a multiplication or a one-line handler that is not passed to a memoized child. You pay the cost of the hook without the benefit.
Worse, memoization can lock in stale closures. If you forget a dependency, the cached function or value will reference outdated state, which leads to bugs that are hard to spot. Many teams enable the react-hooks/exhaustive-deps ESLint rule for exactly this reason. For more on hook patterns, see our piece on custom hooks.
Referential Equality in Practice
Referential equality is the cornerstone of React’s reconciliation. React.memo, useEffect, useMemo, and useCallback all use Object.is to compare values. Two objects with the same contents are not equal.
const a = { id: 1 };
const b = { id: 1 };
Object.is(a, b); // false
This is why passing object literals as props to memoized children breaks memoization. It is also why a memoized callback that depends on changing state still produces a new function whenever that state changes, even if the function body is identical.
Profile First, Optimize Second
The strongest argument against pre-emptive memoization is that it is almost always premature. React is fast by default. A re-render is not a performance problem unless it produces a visible jank or blocks user input.
Use the React Developer Tools Profiler to measure actual render costs. Record an interaction, look at which components rendered, and how long they took. If a component renders in under a millisecond, no amount of useMemo will make your app feel faster. If a component takes 30 ms to render and is hit frequently, you have found a real candidate.
A simple workflow:
- Identify a slow interaction (typing in a field, opening a modal).
- Open the Profiler and record while reproducing it.
- Sort by render time. Investigate the top one or two components.
- Apply the smallest fix possible: split state, lift work, then consider memoization.
For broader context on component composition, see our components and JSX overview.
A Realistic Example
Here is a search box where memoization is worth it.
function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.toLowerCase();
return products.filter((p) => p.name.toLowerCase().includes(q));
}, [products, query]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ProductList items={filtered} />
</div>
);
}
If products is large, filtering on each keystroke is expensive. But if ProductList is also memoized and you pass filtered to it, the memoized array keeps the child from re-rendering when the input is unrelated.
Rules of Thumb
- Do not reach for
useMemooruseCallbackby default. Reach for them when profiling identifies an issue. - If a value is passed as a prop to a memoized child, stability matters.
- If a function or value is used inside a hook dependency array, stability matters.
- For everything else, plain expressions are clearer and just as fast.
Wrap up
useMemo and useCallback are tools for stabilizing references and skipping expensive work. They are not free, they do not make your app faster by themselves, and they can hide bugs behind stale closures. Measure first, memoize second, and keep your component code readable. When in doubt, leave it out.