Practical Memoization in React: When to Use useMemo, useCallback, and memo
Few topics in the React community provoke as much debate and confusion as manual memoization. In codebases around the world, developers often adopt one of two extreme mentalities:
- The Over-Memoizer: Wrapping every function in
useCallback, every object calculation inuseMemo, and every component inReact.memo"just in case." - The Under-Memoizer: Ignoring memoization completely until severe UI lag forces a frantic, unguided optimization audit.
Both extremes are deeply flawed. Manual memoization is not free: it consumes memory by allocating dependency arrays, stores cached references indefinitely, adds cognitive overhead for maintenance, and introduces subtle bugs when dependency arrays fall out of date.
While automated tools like the React Compiler (React documentation) are transforming how codebases handle optimization, understanding how JavaScript referential equality interacts with React’s reconciliation engine remains vital.
In this deep dive, we explore when useMemo, useCallback, and React.memo genuinely improve application performance, how to measure when a calculation is truly expensive, and the common anti-patterns that render memoization completely useless.
The Root Problem: Referential Equality in JavaScript
React’s default behavior is straightforward: when a parent component re-renders, React recursively re-renders all of its children (React documentation).
In JavaScript:
{} !== {} // true: different object references
(() => {}) !== (() => {}) // true: different function instances
Every time a component function executes, inline object literals and function declarations create brand-new memory references. Even if a child component is wrapped in React.memo to skip re-renders when props match, passing an un-memoized object (options={{ limit: 10 }}) or callback (onClick={() => handleClick()}) causes the shallow comparison to fail, forcing the child to re-render anyway.
1. When to Use useMemo: Two Legitimate Scenarios
useMemo caches the result of a calculation between re-renders (React documentation):
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
You should only reach for useMemo in two specific situations:
Scenario A: Genuinely Expensive Calculations
Most JavaScript array operations (such as mapping or filtering 100 items) execute in fractions of a millisecond. In those cases, the overhead of creating a closure, allocating a dependency array, and checking dependencies on every render is actually slower than re-running the loop.
How to know if a calculation is expensive? Benchmark it using the browser console:
console.time('filterTodos');
filterTodos(todos, tab);
console.timeEnd('filterTodos');
If the calculation consistently logs 1ms or longer, wrapping it in useMemo provides a measurable speedup. If it takes 0.05ms, memoizing it is pure overhead.
Scenario B: Preserving Referential Stability for Downstream Dependencies
If you create an object or array and pass it:
- As a prop to a child component wrapped in
React.memo - As a dependency to a
useEffect,useCallback, or custom Hook
Wrapping the object in useMemo prevents unnecessary downstream effect runs and render cascades:
// ✅ Preserves stable reference to prevent child from re-rendering
const chartConfig = useMemo(() => ({
colorScheme: theme,
maxPoints: 50,
}), [theme]);
return <HeavyChart config={chartConfig} />;
2. When to Use useCallback: Passing Handlers to Memoized Children
useCallback caches a function definition between re-renders (React documentation):
const handleSubmit = useCallback((orderDetails) => {
postOrder(productId, orderDetails);
}, [productId]);
The Crucial Insight:
useCallback does not prevent the component creating the callback from doing work. It exists strictly to prevent child components that receive the function from re-rendering.
Therefore, wrapping a function in useCallback is only useful when:
- The function is passed as a prop to a child component wrapped in
React.memo. - The function is passed as a dependency to another Hook (such as a custom Hook's
useEffect).
If a child component is not wrapped in React.memo, wrapping your event handler in useCallback achieves nothing: the child re-renders regardless!
3. When to Use React.memo: Pure Visual Trees
React.memo wraps a component to skip re-rendering if its incoming props are shallowly equal to its previous props (React documentation):
const ProductCard = React.memo(function ProductCard({ product, onSelect }: Props) {
// Expensive rendering tree (charts, maps, heavy SVG icons)
return <div className="card">...</div>;
});
When It Makes Sense:
- The component re-renders frequently with the exact same props (e.g., sibling components re-render when a parent timer ticks, but this component's props never change).
- The component has a heavy, complex subtree with substantial DOM nodes.
When to Avoid It:
- Fast, simple components (e.g.
<Button>,<Badge>, or basic text labels). The overhead of shallowly comparing all prop keys on every render can exceed the cost of simply letting React reconcile the lightweight virtual DOM.
Common Anti-Patterns That Break Memoization
Anti-Pattern 1: Inlining Objects Alongside Memoized Callbacks
// ❌ WRONG: useCallback is wasted because the object creates a new reference
<HeavyChild
onSave={useCallback(() => save(), [])}
theme={{ mode: 'dark' }} // New object breaks React.memo on HeavyChild!
/>
Anti-Pattern 2: Wrapping Everything Without Profiling
Sprinkling useMemo across basic string concatenations or mathematical additions adds thousands of lines of boilerplate and makes codebases brittle to refactor.
Golden Rules Before Memoizing:
- Try Component Composition First: Passing
childrendown often eliminates re-renders without a single memo hook. - Move State Down: If only a small button needs state, keep the state local to that button so the parent tree never re-renders.
- Profile First: Use the React DevTools Profiler to verify that a component is actually causing dropped frames before optimizing it.
Conclusion
Memoization is a surgical scalpel, not a blunt hammer. By reserving useMemo for proven computational bottlenecks, using useCallback strictly alongside React.memo children, and understanding referential equality, developers can build responsive, maintainable React applications that stay fast as codebases grow.