Death by a thousand useCallbacks

May 25, 2020

In reviewing React code, I’ve found that too many times, useCallback()s and useMemo()s used in the codebase are actually counter-productive and hurt performance more than help it. It seems like the prevailing thought process is to useCallback every time you pass a function to a component as a prop “for performance reasons”, but this is both not the right way to think about this, and can actively hurt your render times!

If you want to read another really good article about this, read Kent C Dodds’ blog post about this very issue!

Why is this a problem?

React components trees can be huge. Just open React Devtools and look around your app. We often render a lot of react components at once. One or two unnecessary useCallbacks/useMemos are no big deal - but when they’re everywhere, and they do nothing it can become a problem.

This is a death by a thousand cuts, and together, they add up to longer render times and more memory usage. As well, since they are not even needed, they increase both your code size and complexity for no reason!

A common misconception is that useCallback prevents “function creation during render”. It doesn’t.

What useCallback does is memoize the returned function object based on the provided dependencies. This means that given the same dependencies (compared by reference) it returns the same function object (which can now be used for more reference comparisons and match the previously returned function).

If the component or hook to which you’re passing this useCallback()‘ed function to doesn’t care whether or not it has been given a new function, instead of just creating a new function, you’ve now: created a new function, created a new array, invoked a function (useCallback), and caused React to compare equality of the dependencies and store the function and new set of dependencies into memory.

This is significantly more expensive than just creating and passing the new function as a prop - and often for no reason - the code would have functioned exactly the same with or without the useCallback!

When does referential equality for props matter?

If a child component is wrapped with React.memo(), or extends React.PureComponent, React will bail out of re-rendering it even if the parent re-renders, as long as all of its props are strictly equal. If all other props are referentially equal (children included!), but you pass a new function instance or object instance when you don’t need to, you will cause that component to re-render.

These components can contain large subtrees that are expensive to re-render, so breaking the “Memo” contract can be bad for performance.

Referential equality can also matter when the prop you’re passing is itself used as a dependency for a useEffect(), which would trigger every time that dependency changes.

Some rules to follow

When shouldn’t you useMemo/useCallback?

  1. Don’t useCallback/useMemo for anything you pass to “host” components (divs, spans, a tags, imgs). React does not care whether or not your function reference changes (except for ref functions/mergeRefs patterns with side effects, which should be memoized - See #3 in “when to use” below)
  2. Don’t useCallback/useMemo for anything you pass to “leaf” components. The vast majority of the time, these components aren’t React.memoed anyway, or you’re passing a new “children” reference, so the function reference change doesn’t matter (except for ref functions with side effects, which should be memoized - See #3 in “when to use” below).
  3. Don’t pass a definitely “new object/array” (remember! this includes arrays and functions!) as a dependency to useCallback/useMemo’s list of dependencies. This just means that the dependencies will never be equal, defeating the purpose (ex: const x = [‘hello’]; const cb = useCallback(()={},[prop1,prop2, x]); or const [a, ...rest] = someArray; const cb = useCallback(()={},[rest]);). useCallback/useMemo/useEffect dependencies care about referential equality of the dependencies. Remember, in JS: [‘hello’] !== [‘hello’] and {a: 1} !== {a: 1} and new URI('/') !== new URI('/)
  4. Don’t useCallback/useMemo if the thing you’re passing it to does not care if you pass it a new reference (you can read the code - does it not use React.memo/PureComponent and just immediately access the values in the object you pass it, or pass the function directly to a host/leaf component?). Are you passing it new “children” every time and preventing memoization anyway?

So when should you useMemo/useCallback?

  1. Do useMemo when passing an object as a value to a Context Provider that has many consumers deeply nested in your subtree. <ProductContext.Provider value={{id, name}} > passes a new object to “value” if the top-level parent re-renders for any reason, even if id and name don’t change — which then re-renders every consumer of that provider.
  2. Do useMemo if the thing you’re memoizing is computationally expensive to compute and your inputs are likely to be referentially equal on subsequent renders, for example an expensive map/filter on data.
  3. Do useMemo when passing ref functions with side effects or using mergeRefs-style patterns which create wrapper function refs. Any time a ref function changes, React will unset the old one (call it with null) and call the new one. This might not be what you want, as it might lead to some unnecessarily bookkeeping in your ref function like adding and removing event listeners. For example, a ref returned by a useIntersectionObserver hook will likely disconnect and then re-connect the observer in the ref callbacks.
  4. Do useCallback/useMemo to prevent repeatedly triggering useEffect() in a child (ie: if the useEffect has the function or object as a dependency, changing its reference could repeatedly tear down/create event listeners)
  5. Do useCallback/useMemo in cases where a large subtree would have re-rendered but now does not, because a parent stops the propagation of the render via React.memo() or React.PureComponent() and your prop is the only one changing. Use the React DevTools Profiler to investigate render times on state changes if your component is slow to re-render - this can help you find places to use React.memo to prevent giant cascading re-renders, and yes, if needed, places where you can intentionally useCallback and useMemo to make these state changes more efficient.

© 2020