Skip to main content
CodeOath
← All posts

React120 min total · 17 parts

React Fundamentals: Components, Hooks, and the Virtual DOM

Part 10 of 17 · ~4 min

useMemo, useCallback, and React.memo

The dashboard now has eight hundred candidates in it, and typing in the filter box has started to feel sticky — a noticeable lag between pressing a key and seeing the character.

Before anything else: we know this because the React DevTools Profiler says so, not because it seemed plausible. That ordering is the whole of chapter 15, and it matters here because everything below has a real cost and is worth paying only where something is actually slow.

The Profiler shows one commit dominating the rest. Here is the code it points at:

function Dashboard() {
  const [candidates, setCandidates] = useState([]);
  const [query, setQuery] = useState("");

  const visible = candidates
    .filter((c) => c.name.toLowerCase().includes(query.toLowerCase()))
    .sort((a, b) => b.score - a.score);      // ~30ms over 800 rows
  // ...
}

That filter-and-sort runs on every render of Dashboard — including renders that had nothing to do with the candidate list, like opening the drawer or ticking a checkbox. Thirty milliseconds of work, repeated for no reason.

useMemo caches a computed value between renders and recomputes it only when its dependencies change:

const visible = useMemo(() => {
  const needle = query.toLowerCase();
  return candidates
    .filter((c) => c.name.toLowerCase().includes(needle))
    .sort((a, b) => b.score - a.score);
}, [candidates, query]);

Open the drawer now and the sort does not run. Type a character and it does, because it must.

(A detail that bites: .sort() sorts in place and returns the same array. Sorting candidates directly would mutate state, and React would see the same reference and skip the render. .filter() already returned a new array here, so we are safe — but [...items].sort() is the habit worth having.)

useCallback does the same thing for a function reference:

const handleSelect = useCallback((id) => setSelectedId(id), []);

It is not caching the function's result; it is caching the function object itself, so that the next render hands out the same reference instead of a new one. Mechanically, useCallback(fn, deps) is useMemo(() => fn, deps).

React.memo wraps a component so React skips re-rendering it when its props are shallowly equal to last time:

const CandidateRow = React.memo(function CandidateRow({ candidate, onSelect }) {
  return (
    <tr onClick={() => onSelect(candidate.id)}>
      <td>{candidate.name}</td>
      <td><ScorePill score={candidate.score} /></td>
    </tr>
  );
});

Shallowly equal means each prop compared with Object.is — reference equality for objects and functions, value equality for primitives.

Why they need each other

Wrapping CandidateRow in React.memo and stopping there does nothing whatsoever, and this is the part that wastes people's afternoons:

{visible.map((candidate) => (
  <CandidateRow
    key={candidate.id}
    candidate={candidate}
    onSelect={(id) => setSelectedId(id)}    // a brand-new function, every render
  />
))}

That arrow function is created fresh on every render of Dashboard. A fresh function is never Object.is-equal to the previous one, so React.memo's comparison fails for all eight hundred rows, every time, and they all re-render anyway. You have added a comparison to the cost and removed nothing.

The prop has to be stable for the memo to have anything to skip:

const handleSelect = useCallback((id) => setSelectedId(id), []);

{visible.map((candidate) => (
  <CandidateRow key={candidate.id} candidate={candidate} onSelect={handleSelect} />
))}

Now onSelect is the same reference across renders, candidate is the same object unless that candidate's data actually changed, and typing in the filter box re-renders only the rows whose data differs.

The same trap catches objects, and more quietly. If the row received style={{ opacity: 1 }} or a candidate object rebuilt by a .map() transform in the parent — candidates.map(c => ({ ...c, label: format(c) })) — every row gets a new object every render and memo is defeated again. Whatever you pass to a memoized component has to be stable, not just the functions.

This is the honest reason useMemo and useCallback appear in real code. Not because a computation is slow — most are not — but because a memoized child, or an effect's dependency array, or a context value needs a reference that holds still. Take away a React.memo'd child, a dependency array leaning on it, and a computation expensive enough to matter, and there's nothing left for them to buy you — just one more array of dependencies you're now on the hook to keep accurate for as long as that code lives.

Which is worth sitting with, because it is not free. Every memoized value occupies memory for the life of the component, every dependency array is a comparison on every render, and every one of them is a small correctness liability — a missing dependency in a useMemo is a stale value on screen, which is harder to spot than a stale log line. Re-rendering is cheap. Memoize where you measured a problem.

There is a real prospect of this section aging out. React's optional compiler analyses components at build time and inserts this memoization automatically, which removes most hand-written useMemo and useCallback from codebases that adopt it. The mechanism does not change — it is the same reference stability described above — but the amount of it you write by hand may drop considerably. Understanding what it is doing is what lets you read a profile either way.