React hooks, stable references, and performance

Dovetail Engineering
Dovetail Engineering
6 min readAug 2, 2022

--

By Chris Morgan - Software Engineer

Consensus says that you should only memoize React components when the benefits outweigh the allegedly high cost of memoization — like when you’re working with code executed in very high volumes. But here’s the thing: the consensus is wrong, and it’s costing you. The truth is, the best way to memoize React components is to do it by default.

Memoizing React components isn’t that expensive. And those drawbacks? Pretty limited. When you do it right, memoization is painless and immensely beneficial.

In this article, I’m going to explain why I memoize almost all React components, and how you can do it too.

So what’s memoization, anyway?

Let’s start with the basics. Wikipedia describes memoization as “an optimization technique used primarily to speed up computer programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again.” The word memoize, coined back in 1968, is derived from the Latin word memorandum, meaning “to be remembered.” While memoizing is used in a few different contexts, remembering is its core function.

The technique is often used to save outputs, so you don’t need to repeat calculations that you’ve done before. The use case is valid, but it isn’t why I memoize by default. Instead, I do it to stabilize object references between React renders.

Let’s talk about why we want stable object references and identities.

React rendering

When an external change occurs, usually from a human providing input, states in React change. In response, React re-renders the component where the state changed. A changed component state triggers a re-render of all child components recursively. It’s kind of like a domino effect, so fittingly, we’ll call this event a render cascade. Take the diagram below. Here, the state of ProjectsPage changes. This triggers React to re-render the Header, Sidebar, SearchFilterInput, and all ProjectRows.

Re-renders can be harmless, but problems start when they needlessly occur in complex systems. If you’re rendering many components, or if doing so is expensive, excessive re-rendering can lead to performance issues.

Take the example of a render cascade above. Every time the filter changes (i.e. each keypress), every component — changed or not — in the list is re-rendered. In this example, each component is simple, so the severity of a render cascade would be mild. Not the end of the world. But when you’re dealing with intricate user experiences and many complex components, performance will suffer.

Luckily, there’s an easy solution to this. Because the props here never change, we can short-circuit the execution of each component. To do this, we’ll use React.memo.

Using React.memo to reduce render cascades

React.memo is a tool that makes memoization substantially easier. It’s a higher order component that renders the component wrapped in it and memoizes the outcome. If the props don’t change by the following render, React simply reuses the memoized result instead of calling the function. The process is quite efficient, so if there are consistent props that you render often, use React.memo.

In this example, the change is subtle but impactful. We replaced the type definition of the components, React.FC<Props> with wrapping the component definition in React.memo<Props>. This will prevent the render cascade and improve performance, as shown in the diagram below.

Primitives vs. non-primitives — “The problem”

Equality comparison in JS is a fickle beast. MDN published a fantastic article about it if you want to learn more. For now, we need to talk about comparing primitives and non-primitives.

When JS checks primitives (strings, numbers, and booleans), it uses proper equality, which evaluates equality using value. For example, under proper equality, 1 is always equal to 1, 1 is not equal to 2, etc. Primitives are always stable, so we don’t need to worry about them here.

What we do need to worry about, are non-primitive (objects, arrays, functions, and symbols). Here, JS equality checks use referential equality. Referential equality considers the equality of objects based on their position in memory. So two objects that look the same but point to different memory locations, are unequal.

By default, non-primitive values are volatile between renders. This means that a non-primitive prop passed to a React component will render more often than needed. Volatile non-primitive values used as React hook dependencies can cause them to execute too often.

A common (not so good) solution

Documentation and intro guides generally use inline functions, arrays, and objects as props to components. The problem is, this always creates a new reference for each render and never leads to dependencies being equal. If dependencies are never equal, memoization is useless. Take the example below. The inline definition of the onSelect function creates volatile references that invalidate the memoization with every render. This induces the dreaded render cascade of all the ProjectListItem components.

Problematic? I think so. And still, this inline definition is recommended in many tutorials, guides, and documentation. So how can we deal with this differently?

The solution

You start by extracting the onSelect prop to defined within a useCallback hook, and alter it to have one argument: the id of the project selected. By doing this, the function is memoized and made completely stable as the hook has no dependencies.

You can also pass the id of the project as a prop to ProjectListItem. This neat trick turns all props into stable references, as we can call onSelect with said id.

When you select a project after completing these steps, only the ProjectListItem will be re-rendered, and a rendering cascade is avoided.

Below is a simple example of memoized non-primitives fixing memoization.

Any non-primitive value should be wrapped in a useMemo / useCallback hook by default to ensure stable references. Otherwise, the component memoization mentioned earlier is useless.

Stabilizing references with useRef

We went over the primary solutions to this problem, but I want to take a second to share a few extra tips that will make memoization even more effective.

React describes refs as “a way to access DOM nodes or React elements created in the render method.” When you need to modify a child without rendering it with new props, take advantage of refs. [https://reactjs.org/docs/refs-and-the-dom.html]

The useRef hook provides developers with a convenient object to interface with the React refs within function components. While this is considered useRef’s primary function, it has another super helpful use case when stabilizing references: useRef can be used to wrap volatile references in a stable container, essentially allowing you to grant dependencies immunity to change. Because sometimes, you don’t want hooks to trigger when input variables change.

Take useEffect, this cool tool that allows you to invoke pretty much any side effect (fetching data, reading from local storage, etc.) from within functional components.

In this case, each time count is incremented, the useEffect is re-evaluated and the setInterval gets replaced with a new one. This leads to inconsistent timing of the logging. This is a contrived example, but one where you would want to have more fine-grained control of when hooks are re-evaluated. Something we use at Dovetail is a custom helper hook called useLiveRef, a useful solution for this problem.

So rather than adding an exception to the react-hooks/exhaustive-deps ESLint rule, which you’re probably using, and excluding count from the dependencies array of the useEffect, the useLiveRef hook can provide a stable reference to the current count.

Conclusion

Memoization can — and should be! — done by default, unless it’s been proven that doing so won’t have positive results. By practicing memoization, you can avoid unnecessary re-renders by stabilizing references and identities, improving React’s performance. It’s not as expensive as you think, and it’s always worth it.

We have loads of engineering roles available. Come and join us on this amazing journey. Visit our careers page!

--

--

Dovetail Engineering
Dovetail Engineering

Read about how Dovetail engineering designs, builds, and operates.