Back to the Library

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.”

In This Post

What the Dependency Array PromisesToo Few Dependencies: Stale ForeverToo Many Dependencies: Recomputes for No ReasonThe Rule: List What You Read, Nothing ElsePractice These Patterns

Practice This Pattern

Blue Belt

List Ignores Everything You Type

A search box filters a list using useMemo with an empty dependency array - the filtered result is computed once on mount and never recalculated, no matter what's typed.

+25 KI
Enter the Dojo
Blue Belt

Filter Reruns on Every Like Click

A name filter wrapped in useMemo recomputes every time an unrelated Like button is clicked - the dependency array lists a value the calculation never actually reads.

+25 KI
Enter the Dojo
BugDojo
BlogFAQ

© 2026. Carved in code.

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.

What the Dependency Array Promises

const filtered = useMemo(() => {
  return ITEMS.filter((i) => i.toLowerCase().includes(query.toLowerCase()));
}, [query]);

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.

Too Few Dependencies: Stale Forever

const ITEMS = ["Apple", "Banana", "Avocado", "Blueberry", "Cherry", "Apricot"];
 
export default function MemoSearch() {
  const [query, setQuery] = useState("");
 
  const filtered = useMemo(
    () => ITEMS.filter((i) => i.toLowerCase().includes(query.toLowerCase())),
    [],
  );
 
  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>{filtered.map((item) => <li key={item}>{item}</li>)}</ul>
    </div>
  );
}

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.

const filtered = useMemo(
  () => ITEMS.filter((i) => i.toLowerCase().includes(query.toLowerCase())),
  [query],
);

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 current query, and the result reflects what's actually been typed.

Too Many Dependencies: Recomputes for No Reason

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.

const NAMES = ["Alice", "Bob", "Carol", "Dave", "Eve"];
 
function runFilter(names, query) {
  return names.filter((n) => n.toLowerCase().includes(query.toLowerCase()));
}
 
export default function NameSearch() {
  const [query, setQuery] = useState("");
  const [likes, setLikes] = useState(0);
  const [runCount, setRunCount] = useState(0);
 
  const filtered = useMemo(() => {
    setRunCount((r) => r + 1);
    return runFilter(NAMES, query);
  }, [query, likes]);
 
  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>{filtered.map((name) => <li key={name}>{name}</li>)}</ul>
      <button onClick={() => setLikes((l) => l + 1)}>Like ({likes})</button>
      <p>Filter ran: {runCount} times</p>
    </div>
  );
}

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.

const filtered = useMemo(() => {
  setRunCount((r) => r + 1);
  return runFilter(NAMES, query);
}, [query]);

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.

The Rule: List What You Read, Nothing Else

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.

Practice These Patterns

"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.