Memoization Pitfalls in React: When useMemo and useCallback Make Things Worse
useMemo and useCallback do one thing: cache a value or function reference between renders, returning the cached version unless the dependency array has changed. The intent is performance: skip expensive recalculations, or maintain referential equality to prevent unnecessary child re-renders.
The problem is that memoization is not free. It always costs: memory to store the cached value, a comparison on every render to check if dependencies changed, and cognitive overhead for every developer who reads the code. For the optimization to be net positive, the savings from skipping a calculation or re-render must exceed this cost.
Most of the time, they do not.
Pitfall 1: Memoizing Cheap Computations
// ❌ Pointless — the computation is cheaper than the memoization overhead
const doubled = useMemo(() => count * 2, [count])
// ✅ Just compute it
const doubled = count * 2
useMemo has real overhead: the dependency comparison, the cache lookup, and the wrapper function call. These costs are measurable. For operations that complete in nanoseconds (arithmetic, simple string operations), memoization adds more cost than it saves.
The rule: useMemo is worth it only when the computation is genuinely expensive — sorting large arrays, complex data transformations, or operations that create many objects.
Pitfall 2: useCallback Without React.memo
// ❌ useCallback does nothing useful here
function Parent() {
const handleClick = useCallback(() => doSomething(), [])
return <Child onClick={handleClick} />
}
function Child({ onClick }) {
return <button onClick={onClick}>Click</button>
}
useCallback maintains referential equality of handleClick across renders. But Child re-renders whenever Parent re-renders regardless — because referential equality of props only prevents re-renders if the child is wrapped in React.memo.
// ✅ useCallback + React.memo — now it works
const Child = React.memo(function Child({ onClick }) {
return <button onClick={onClick}>Click</button>
})
Without React.memo on Child, useCallback is pure overhead.
Pitfall 3: Missed Dependencies Breaking Bugs
// ❌ stale closure — count is always 0 inside the callback
const increment = useCallback(() => {
setCount(count + 1) // 'count' is missing from deps
}, []) // eslint-disable-line react-hooks/exhaustive-deps
Excluding dependencies from the array to avoid re-creating the callback creates stale closures — the function captures an old value of a variable and never sees updates. The correct fix is to use the functional form of setState, which does not require the current value in scope:
// ✅ always correct
const increment = useCallback(() => {
setCount(c => c + 1)
}, [])
Or, if the computation genuinely needs the current value, include it in the dependency array and accept that the callback changes when the value changes.
Pitfall 4: useMemo Doesn't Guarantee Retention
React does not guarantee memoized values are retained between renders. If React needs to free memory (e.g. in Concurrent Mode when it discards in-progress renders), it may discard cached values. useMemo is an optimization hint, not a semantic guarantee.
Critically: never use useMemo to ensure a value is computed only once. For initialization logic that must run exactly once:
// ❌ NOT a semantic guarantee
const store = useMemo(() => createExpensiveStore(), [])
// ✅ For true once-only initialization
const storeRef = useRef(null)
if (storeRef.current === null) {
storeRef.current = createExpensiveStore()
}
const store = storeRef.current
Pitfall 5: Object and Array Literals as Dependencies
A common source of infinite re-renders or over-memoization: passing object or array literals as dependency array values:
// ❌ New object reference every render — memoization always invalidates
const options = useMemo(
() => processData(rawData),
[{ threshold: 100 }] // new object literal every render
)
Object and array literals create new references on every execution. The dependency comparison is by reference, not value. Either define the object outside the component, inside another useMemo, or use a stable primitive value.
When Memoization Does Help
The cases where useMemo/useCallback genuinely pay off:
- Expensive list operations: Filtering/sorting a list of thousands of items when the dependencies change infrequently
- Referential stability for
React.memochildren: Stable prop references prevent child re-renders in deeply nested trees or components that render frequently - Stable dependencies for other hooks: A callback passed to
useEffectneeds stable identity to avoid the effect running on every render
The test: profile with React DevTools or the Performance panel. If a component is re-rendering frequently and the renders are visibly slow, then memoize. Do not memoize preemptively.
Conclusion
Memoization in React should be applied in response to measured performance problems, not as a default coding style. useCallback without React.memo does nothing. useMemo for cheap computations adds cost instead of saving it. And incomplete dependency arrays are a source of subtle bugs worse than the performance issues the memoization was meant to fix. The React team's guidance is consistent: write clear code first, measure second, and memoize only where you have verified a bottleneck.
