Memoization Pitfalls

TL;DR / useMemo and useCallback are not free optimizations — they add comparison overhead, memory cost, and code complexity that often exceeds the cost of the work they're trying to skip.

How It Works

 useMemo decision flow

 ┌──────────┐          ┌────────────┐     yes    ┌───────────────┐
 │  Render  │─────────→│ Deps same? │───────────→│ Return cached │
 └──────────┘          └────────────┘            └───────────────┘
                              │
                              │
                             no
                              │
                              ↓
                    ┌───────────────────┐
                    │ Recompute + store │
                    └───────────────────┘

 Cost: deps comparison + cache + GC pressure
 Worth it ONLY when recompute > comparison

Edit diagram

Every call to useMemo or useCallback does work: React must allocate an array to store the dependencies, store the cached result, and on every subsequent render compare each dependency against the previous value using Object.is. This comparison loop runs unconditionally — even when the cached value is returned. The question is whether this overhead is less than the cost of just recomputing the value.

When Memoization Hurts

For cheap computations — filtering a small array, formatting a string, computing a derived value from two or three primitives — the memoization overhead often exceeds the computation cost. Creating a closure, allocating a dependency array, and running Object.is on each dep is not free. When the computation itself takes microseconds, wrapping it in useMemo makes the code slower, not faster.

The same applies to useCallback. Wrapping every event handler in useCallback is a common anti-pattern. If the child receiving the callback is not wrapped in React.memo, the stable reference provides zero benefit — the child re-renders when the parent does regardless of prop identity.

The Dependency Array Trap

Memoization is only as good as its dependencies. If any dependency changes on every render, the memoized value recomputes every time, giving you the worst of both worlds: the overhead of memoization plus the full computation cost.

Common culprits include:

  • Object or array literals in the dependency array: useMemo(() => compute(config), [config]) where config is { threshold: 10 } created inline each render.
  • Unstable function references: depending on a callback that isn't itself memoized.
  • Selector results that create new objects: a Redux selector returning { ...state.user } changes reference every dispatch.

Cascading Memoization

Once you memoize one value, consumers of that value may also need memoization to benefit from the stable reference. This creates a cascade: you useMemo a computed list, then useCallback a handler that references it, then React.memo the child that receives both. Miss any link in the chain and the memoization at the top provides no benefit.

This cascade is the hidden cost — not in performance, but in cognitive overhead. Every memoized value becomes a contract: "I promise this reference is stable unless these specific inputs change." Breaking that contract (by adding an unstable dep or removing a memo) silently defeats downstream optimizations.

When Memoization Helps

Memoization earns its keep in specific scenarios:

Expensive computations. If you are sorting thousands of items, computing a complex layout, or running a statistical calculation on every render, useMemo prevents redundant work. Profile first — if the computation takes less than a millisecond, it is not expensive.

Stable references for memoized children. When a child component is wrapped in React.memo and you pass it an object, array, or function prop, stabilizing that prop's reference with useMemo/useCallback prevents the child from re-rendering. This is worth it when the child's render is expensive — deep component trees, many DOM nodes, or costly effects.

Context values. If a context provider creates a new value object each render, every consumer re-renders. Memoizing the context value is almost always worth it because context consumers can be numerous and deep in the tree.

React Compiler Makes This Moot

React Compiler (the successor to React Forget) statically analyzes component code and inserts memoization automatically where it determines the benefit outweighs the cost. It understands data flow through the component, tracks which values are stable, and avoids memoizing cheap operations. For teams that adopt the compiler, manual useMemo and useCallback become unnecessary in most cases.

Until then, the rule of thumb is: profile before you memoize. Premature memoization is premature optimization with the added cost of dependency array maintenance.

Gotchas

  • useMemo does not guarantee the cache persists. React's docs explicitly state it may discard cached values to free memory. Never use useMemo for semantic correctness — only for performance.
  • useCallback(fn, deps) is just useMemo(() => fn, deps). It does not make the function faster; it only stabilizes the reference. If nothing downstream checks that reference, it is wasted work.
  • Empty dependency arrays (useMemo(() => val, [])) cache forever for the component's lifetime. This is fine for truly constant values but can cause stale data if the value should have dependencies you forgot.
  • Memoization masks bugs. If a component works only because a value is memoized (e.g., an effect that should re-run but doesn't because a dep is stable), removing the memo breaks behavior. This couples correctness to an optimization, which is fragile.
  • Profiling is essential. React DevTools Profiler and Chrome DevTools can show you render costs. If you cannot measure the improvement, the memoization is likely not helping.