When I reach for React's memo, useMemo and useCallback

A lot of the memoisation in a React codebase does nothing, and some of it costs more than it saves. These are the few cases where I still use it.

Last month at React Advanced in London the React team showed React Forget again, the compiler that is supposed to memoise your components so you don't have to. It has been coming since React Conf 2021. Meanwhile every code review I do has the same two comments in it: "this useMemo does nothing" and "this memo is useless without a useCallback two lines up".

So here is how I decide, written down so I can link it instead of typing it again.

The default is no

A React component re-rendering is usually cheap. It calls a function, compares some objects, touches the DOM where something changed. Most of the memoisation I see is a useMemo around a .filter() on twenty items or a useCallback on a handler that goes straight into a <button>. Those cost a dependency comparison and a kept closure on every render and save nothing, because nothing was comparing the result.

Measure first. The React DevTools Profiler shows which components rendered on an interaction and how long each took, and the "Highlight updates when components render" checkbox shows it live. If the Profiler doesn't show a problem, I don't memoise.

Before memo, move things around

Dan Abramov wrote this up in 2021 and I still send the link in reviews. Two moves fix most "the whole page re-renders when I type" problems without a single hook.

Move state down. If an input's state lives in the page, every keystroke re-renders the page and everything in it. Put the input and its state in a small component and the page never hears about typing.

jsx
// src/pages/Report.jsx, before: every keystroke renders the chart too
export function Report({ data }) {
  const [note, setNote] = useState('');
  return (
    <div>
      <input value={note} onChange={(event) => setNote(event.target.value)} />
      <ExpensiveChart data={data} />
    </div>
  );
}

// after: the note owns its state, Report only renders when data changes
export function Report({ data }) {
  return (
    <div>
      <NoteField />
      <ExpensiveChart data={data} />
    </div>
  );
}

Lift content up. When the state has to stay where it is, pass the expensive part in as children. The parent that owns the state receives the chart as a prop it didn't create, so when it re-renders, React sees the same element and skips it.

jsx
// src/pages/Report.jsx, the state stays, the chart comes from outside
function NotePanel({ children }) {
  const [note, setNote] = useState('');
  return (
    <div>
      <input value={note} onChange={(event) => setNote(event.target.value)} />
      {children}
    </div>
  );
}

export function Report({ data }) {
  return (
    <NotePanel>
      <ExpensiveChart data={data} />
    </NotePanel>
  );
}

Only when neither move is possible do I reach for the hooks.

memo

memo skips a component's render when its props are shallowly equal to last time. The case for it: a parent that renders often for its own reasons, a timer, a websocket counter, a form, and a child that is expensive and doesn't care.

It only works if every prop survives ===. Strings and numbers do. Objects, arrays and functions created during the render don't, so this is where the other two hooks come in. memo is a package deal: once you memoise a component, every non primitive prop it receives has to be made stable, or you've added a comparison that always fails.

What defeats it silently:

  • An inline object, style={{ marginTop: 8 }}.
  • An inline handler, onSelect={(id) => setSelected(id)}.
  • children as JSX. A new element every render, so a memoised layout component with children re-renders every time anyway.
  • A spread of a fresh object, {...buildProps()}.

useCallback

One job: keep a function's identity stable because something downstream compares it. That something is a memoised child, or the dependency array of an effect.

jsx
// src/pages/Members.jsx
const handleSelect = useCallback((id) => {
  setSelectedId(id);
}, []);

// MemberTable is memo'd, this is the only reason handleSelect is wrapped
<MemberTable members={members} onSelect={handleSelect} />

Two things people miss. The setter from useState is already stable, so if the handler is only (id) => setSelectedId(id), pass setSelectedId and skip the hook. And a handler passed to a DOM element or a normal component gains nothing from useCallback, nobody is comparing it.

Custom hooks that return functions are the other legitimate case. If useThing() returns refresh and callers put it in an effect's dependencies, it had better not change every render.

useMemo

Two reasons, and I want to know which one I'm using when I write it.

The calculation is expensive. Sorting or grouping a few thousand rows, building a lookup Map, running a search index. The React docs give a rule of thumb I agree with: wrap it in console.time, and if it adds up to around a millisecond or more, memoise it. Below that you're paying for the hook.

The identity matters downstream. The classic is a context value:

jsx
// src/context/SettingsProvider.jsx
const value = useMemo(() => ({ theme, locale, setTheme, setLocale }), [theme, locale]);

return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;

Without it the provider creates a new object on every render and every consumer re-renders, whether theme changed or not. Same for an object passed to a memoised child or used as an effect dependency.

One thing useMemo is not: a guarantee. React is allowed to throw a memoised value away and recompute it, and the docs say so. If your code breaks when the memo is dropped, it was relying on it for correctness and that's a bug waiting for a React version.

Two things I stopped memoising

Data from a query cache. TanStack Query keeps the same reference when a refetch returns the same data, so useMemo(() => data, [data]) and friends are pure noise. The cache already did the comparison, better than a dependency array can.

And rendering that's slow because it's big, not because it's repeated. memo doesn't make a three thousand row table render faster, it only skips the renders that didn't need to happen. For the ones that do, defer the value or virtualise.

The short list

Tool Reach for it when Leave it out when
memo The parent renders often, the child is expensive, and its props can be made stable A prop is an inline object, an inline function or children
useCallback The function goes to a memoised child or into a dependency array It goes to a DOM element or a component that isn't memoised
useMemo The work is around a millisecond or more, or the result's identity is compared downstream The work is cheap and nobody compares the result

About that compiler

React Forget reads your component, works out what depends on what, and inserts the memoisation at build time. You write the plain version and the output has the useMemo calls you would have placed by hand, and a few you wouldn't have thought of. At React Advanced, Joe Savona and Mofei Zhang showed it running and said it is already running on parts of Meta's own apps. It is not released and there is no date.

What it asks of your code is the same thing the docs have asked all along: components and hooks that are pure during render, no mutating props or state, no reading from things that change outside React's knowledge. Code that follows those rules gets memoised for free one day. Code that doesn't gets skipped by the compiler and stays as slow as it is. If it ships, my short list gets shorter, and the code I wrote without memo will be the code that benefits most.

Read more here: