useMemo and useCallback Memory Cost

The team wrapped everything in useMemo and useCallback to stop re-renders, and a list of 2,000 rows now holds 2,000 cached derived arrays, the heap is 60 MB larger, and a snapshot shows old versions of a large dataset still alive after an update. This guide from React Component Memory Leaks and Lifecycle Cleanup, part of Framework-Specific Memory Optimization, explains exactly what these hooks retain, when that memory is worth it, and how stale closures turn memoization into retention of outdated data.

Symptom Root Cause Immediate Action Measurable Impact
Heap scales with rendered rows × derived data useMemo in every row caches a derived structure per instance Compute derived data once in the parent, pass slices One copy instead of N
Old version of a big object alive after update useCallback/useMemo closure captured it and deps did not change Include the object in deps or read it via a ref Stale copy released
Memoization everywhere, few re-renders avoided Memo costs (deps arrays, closures, cached values) exceed savings Profile renders; keep memo where it prevents real work Less memory and simpler code
Large cached value kept while component is hidden Memoized value lives as long as the instance Unmount hidden heavy views, or derive lazily Memory tied to visibility
Memoized context value retains whole store Provider memoizes an object capturing large state Split context; memoize minimal values Consumers retain less

Root Cause: Memoization Is Per-Instance Caching

useMemo(fn, deps) stores two things in the component’s hook state: the last computed value and the dependency array used to compute it. On each render, React compares the new dependencies with the stored ones and either returns the cached value or recomputes and replaces it. useCallback(fn, deps) is the same with the function itself as the cached value. So each memo hook permanently holds, for as long as the component instance is mounted, one cached value, one dependency array, and — for callbacks and for any memoized value that contains functions — the closure context of the render that created it.

That is usually small. It becomes significant in two situations. The first is multiplication: a memo inside a component that is rendered many times — list rows, table cells, map markers — caches one value per instance. If each row memoizes a derived array of its own data, or a formatted structure, total memory is rows × derived size. Memoization moved computation cost into memory cost, and with thousands of instances the memory side dominates. Deriving data once in the parent (or in a selector) and passing plain values down is usually both faster and smaller.

The second is stale closures. A callback created in one render captures the variables of that render through its closure context. If its dependency list omits a large value that the closure can still reach — for example a big rows array from the same scope — then after rows is replaced by a new version, the memoized callback continues to hold the old rows. Memory now contains both versions until the callback is recreated. The same happens with memoized values that embed functions or references to earlier state. The lint rule react-hooks/exhaustive-deps exists largely to prevent the correctness side of this; the memory side is its quieter twin.

Finally, memoized values live as long as the instance, not as long as they are useful. A hidden tab that stays mounted keeps its memoized 20 MB derived dataset. The React Compiler and other automatic memoization change who writes the memo, not the fact that caches are per instance — and views that stay mounted across navigation, discussed in why React memory grows on every route change, keep their caches too.

Where memoized memory goes Left: 2,000 Row components each hold a useMemo cache with its own derived array, so derived memory is multiplied by the number of rows. Middle: the parent derives once and rows receive plain values, so there is one derived structure. Right: a useCallback created with an incomplete dependency list keeps the previous rows array alive after an update, so two versions coexist. useMemo in every row Row #1 · cached derived[] Row #2 · cached derived[] Row #2000 · cached derived[] memory × 2,000 derive once in parent parent: one derived structure rows receive plain values (React.memo on Row if needed) memory × 1 stale useCallback current rows (v2) old rows (v1) held by closure deps omitted rows → two versions alive

Step-by-Step Fix

  1. Measure memo retention. Take a heap snapshot with the heavy view mounted, then unmount it and take another; compare retained sizes in Comparison view. Verification: you know how much memory the view’s component instances hold.
  2. Find multiplied memos. Look for useMemo/useCallback inside components rendered in lists, grids or many instances. Verification: you have a list of per-instance memos and what each caches.
  3. Lift derivation to one place. Compute derived collections once in the parent or a selector and pass each child only what it renders. Keep React.memo on the child if re-renders are expensive. Verification: the derived structure exists once in the snapshot.
  4. Fix incomplete dependencies. Enable react-hooks/exhaustive-deps, include every captured value in deps, or read frequently changing large values through a ref inside the callback. Verification: after an update, snapshots contain only the current version of large objects.
  5. Remove memos that do not pay. Use the React Profiler (React DevTools → Profiler) to confirm which memos prevent meaningful renders; remove the rest. Verification: render time is unchanged or better, and the per-instance hook count falls.
  6. Unmount heavy hidden views. Where hidden tabs keep large memoized values, unmount them or derive lazily on show. Verification: switching away from a heavy tab releases its memory.
2,000-row table: where memoization lives Per-row useMemo caching formatted cell data retains about 58 megabytes. Deriving once in the parent retains about 12 megabytes. Deriving in the parent and virtualizing to render only 40 rows retains about 7 megabytes. Retained by the table view (MB) per-row useMemo 58 derive once in parent 12 + virtualized rows 7

Command and Code Reference

Use case: move per-row derivation to the parent.

// Before: every row memoizes its own formatted cells
function Row({ record, locale }) {
  const cells = useMemo(() => formatCells(record, locale), [record, locale]); // × N rows
  return <tr>{cells.map((c) => <td key={c.key}>{c.text}</td>)}</tr>;
}

// After: one derived structure; rows are memoized components receiving plain data
function Table({ records, locale }) {
  const formatted = useMemo(() => records.map((r) => formatCells(r, locale)), [records, locale]);
  return <tbody>{formatted.map((cells, i) => <MemoRow key={records[i].id} cells={cells} />)}</tbody>;
}
const MemoRow = React.memo(function MemoRow({ cells }) {
  return <tr>{cells.map((c) => <td key={c.key}>{c.text}</td>)}</tr>;
});

Use case: avoid pinning an old large value in a callback. Read the latest value through a ref when the callback must stay stable.

// Leaky: deps omit `rows`, so the callback keeps the rows from the first render
const exportCsv = useCallback(() => download(toCsv(rows)), []); // stale and pins old rows

// Correct and stable: ref always points at the current rows, closure holds only the ref
const rowsRef = useRef(rows);
useEffect(() => { rowsRef.current = rows; }, [rows]);
const exportCsvLatest = useCallback(() => download(toCsv(rowsRef.current)), []);

Verification and Regression Prevention

After the change, compare heap snapshots of the mounted view before and after: derived structures should appear once rather than per instance, and after a data update only one version of each large object should be alive. The React Profiler should show that rendering cost did not regress — if it did, reintroduce memoization at the component boundary (React.memo) rather than per-instance derived caches.

Keep react-hooks/exhaustive-deps as an error, not a warning, and add a code-review guideline: memoize to prevent measured expensive work, not by default; derive collections once, near the data. For long lists, combine with virtualized list DOM recycling so the number of instances — and therefore per-instance hook state — stays small.

After removing per-instance memo caches Compare heap snapshots of the mounted view before and after: derived structures should appear once rather than per instance, and after a data update only one version of each large object should be alive. The React Profiler should show rendering cost did not regress; if it did, memoize at the component boundary with React.memo instead. Mounted view, before vs after Derived data once Shared structures appear once, not per component instance. One version alive After a data update, old large objects are gone. Render cost held React Profiler shows no regression; else use React.memo.

Edge Cases and Gotchas

React may drop memoized values

React’s documentation reserves the right to discard useMemo caches in some situations. Do not use useMemo as a semantic guarantee (for example to keep an expensive object alive); use state or refs for that, and treat useMemo purely as a performance hint.

Memoized context values

A provider that memoizes { state, dispatch, helpers } gives every consumer a reference to the whole object. Consumers that stash it in long-lived places (subscriptions, module caches) retain all of it. Split contexts and memoize minimal values.

Inline objects in deps

Dependencies that are new objects every render ({ page, size } built inline) make the memo recompute every time, paying both the computation and the allocation of a new cached value. Depend on primitives or stable references.

The compiler does not remove per-instance cost

Automatic memoization caches values per component instance just like hand-written hooks. It reduces re-rendering work, but the multiplication effect in large lists still applies; structure data flow so derived data is computed once.

Frequently Asked Questions

Do useMemo and useCallback use memory?

Yes. Each hook stores its cached value and its dependency array in the component’s hook state for as long as the instance is mounted, and callbacks keep their render’s closure alive. Usually this is small, but it multiplies with the number of instances.

Can useCallback cause a memory leak?

Not by itself, since its memory is released on unmount. But with incomplete dependencies it can keep outdated large objects alive while the component is mounted, which looks like a leak in long-lived views. Include all captured values in deps or read changing values through refs.

Should I wrap every function in useCallback?

No. Use it when a stable function identity prevents meaningful re-renders (for example a memoized child) or is required by an effect’s dependencies. Elsewhere it adds hook state and closures without benefit.

Is it better to compute in the parent or in each child?

For derived data about many items, computing once in the parent (or a selector) is typically smaller and often faster than per-child memos. Pass children only the values they render, and memoize the child component if its render is expensive.