React useMemo Not Updating - Or Recalculating Too Often
6 min read
“useMemo has exactly two ways to go wrong, and they're mirror images. Either it never recalculates when it should - your list stays frozen forever - or it recalculates when it shouldn't, on every render, for reasons that have nothing to do with what it's computing.”
useMemo has exactly one job: cache the result of a calculation, and only
redo that calculation when something it depends on actually changes.
There are exactly two ways for that to go wrong. Either the dependency
array is missing something the calculation reads - and the cached result
goes stale, forever. Or it lists something the calculation doesn't read -
and the cache is invalidated for no reason, every time that unrelated thing
changes.
Both bugs look identical at the call site. useMemo(() => ..., [...]). The
only difference is what's inside the brackets versus what's inside the
function - and whether those two things actually match.
The array is a list of "things this calculation depends on." React's
contract is: run the function and cache the result; on future renders, if
every value in this array is the same as last time, skip the function
entirely and return the cached result; if anything in the array differs,
re-run the function and cache the new result.
This only works correctly if the array is accurate - every value the
function reads is listed, and nothing else is.
On mount, query is "". useMemo runs the function once, filters
ITEMS by "" (matches everything), caches all six items as filtered,
and - because the dependency array is [] - has nothing left to ever
invalidate that cache.
Type "a". query becomes "a". The component re-renders. useMemo checks
its dependency array: []. Nothing in an empty array can be "different from
last time" - there's nothing to compare. The cache is still considered
valid. React returns the same six-item array it cached on mount, without
re-running the filter function at all.
The function does read query - that's the whole point of it - but
query isn't in the array, so useMemo has no way to know that query
changing should matter.
Symptom
The input field updates correctly as you type. The list underneath shows
all six items, unchanged, regardless of what's typed - it was computed
once, on mount, and the cache has never been told to reconsider.
Adding query to the array makes the contract accurate again: "this
calculation depends on query." Now, every time query changes, the cache
is invalidated, the filter function runs again with the currentquery,
and the result reflects what's actually been typed.
The opposite bug doesn't produce a visibly wrong result - the displayed
list is always correct. What's wrong is a counter tracking how often the
calculation runs, climbing in situations where it shouldn't.
On mount, "Filter ran: 1 time." Click "Like" - a button that has nothing to
do with the search. "Filter ran: 2 times." Click it twice more: "Filter ran:
4 times" - despite query never having changed.
runFilter(NAMES, query) reads exactly two things: NAMES (a module-level
constant, never changes) and query. It does not read likes anywhere.
But likes is in the dependency array - [query, likes] - so every time
likes changes, useMemo sees a "different" dependency and reruns the
function, even though the function's actual output couldn't possibly be
affected by likes.
The Sensei's Hint
This version is harder to catch in review than the first, because the
output is always correct - filtered always shows the right names. The
only symptom is wasted recomputation, which here is made visible
deliberately via runCount. In real code, this shows up as unnecessary
work on every unrelated state change - more significant the more expensive
the memoized calculation is.
Removing likes from the array makes the contract accurate in the other
direction: "this calculation depends on query - and only query." Now
useMemo's cache is only invalidated when query actually changes, and
clicking "Like" no longer triggers a recompute. "Filter ran: 1 time" stays
at 1 until you actually type something.
Both bugs are the same underlying issue from opposite directions: the
dependency array and the function body have drifted apart. The array should
be a precise list of every value from component scope that the function
body reads - no more, no less.
Constraints
Too few dependencies: the result goes stale when something it depends on
changes, because useMemo was never told to care. Too many dependencies:
the result recomputes when something unrelated changes, because
useMemo was told to care about something it doesn't actually use. Both
are fixed by making the array match the function body - exactly the
values read, exactly once each.
ESLint's react-hooks/exhaustive-deps rule checks for the first kind
automatically - it flags values used inside the function but missing from
the array. It generally won't flag the second kind (an extra, unused
dependency) as an error, since listing more dependencies than necessary is
"safe" in the sense that it can't produce a wrong result - only wasted
work. That's exactly why it's worth checking for by hand.
"List Ignores Everything You Type" is the missing-dependency version - watch
the list stay frozen at six items no matter what you type, then add query
to the array and watch it start responding. "Filter Reruns on Every Like
Click" is the opposite: the list is already correct, and the bug is purely
about how often the calculation re-runs - use the runCount display to see
the recomputation happening in real time, then remove the unused dependency
and watch it stop.