How to avoid this React Hooks performance pitfall
React Hooks promise to avoid the overhead of class components while delivering all the same benefits. For example, they allow us to write stateful functional components without having to worry about storing state on the class instance.
However, writing stateful components with Hooks requires care. There’s a subtle difference between how state is initialized in the constructor of a class component and how it is initialized by the useState
hook. Developers who already understand class components and think of Hooks simply as class components without the class stuff are at risk of writing components that perform worse than class components.
Here, I discuss a feature of useState
that is only mentioned briefly in the official Hooks FAQ. Understanding this feature in detail will allow you to get the most out of React Hooks. In addition to reading this note, I invite you to play around with Stress Testing React Hooks, a benchmark tool I wrote to illustrate these peculiarities of Hooks.
The options prior to React Hooks
Suppose you have some expensive calculation that needs to happen just once when setting up your component, and suppose that this calculation depends on some prop. A plain functional component does a very bad job at this:
This performs very poorly, because the expensive calculation is carried out on every render.
Class components improve on this by allowing us to carry out a given operation only once, for example in the constructor:
By storing the result of the calculation on the instance, in this case inside of the component’s local state, we can bypass the expensive calculation on each subsequent render. You can see the difference that this makes by comparing the class component and the functional component with my benchmark tool.
But class components have their own drawbacks, as mentioned in the official React Hooks docs. That’s why Hooks were introduced.
A naive implementation with useState
The useState
hook can be used to declare a "state variable" and set it to an initial value. That value can be changed and accessed in subsequent renders. With that in mind, you may naively try to do the following to improve the performance of your functional component:
You may think that since we’re dealing with state here that is shared between subsequent renders, the expensive calculation is only carried out on the first render, just like with class components. You’d be wrong.
To see why, recall that NaiveHooksComponent
is a just a function, a function that is invoked on each render. That means that useState
is invoked on each render. How useState
works is a complicated story that need not concern us. What's important is what useState
is invoked with: It's invoked with the return value of expensiveCalculation
. But we will only know what that return value is if we actually invoke expensiveCalculation
. As a result, our NaiveHooksComponent
is doomed to carry out the expensive calculation on each render, just like our previous FunctionalComponent
that didn't use useState
.
So far, useState
doesn't give us any performance benefits, as can be verified with my benchmark tool. (Of course, the array that useState
returns also contains a function that allows us to easily update the state variable, which is something we couldn't do with a simple functional component.)
Three ways to memoize expensive calculations
Fortunately, React Hooks provide us with three options to handle state that are just as performant as class components.
1. useMemo
The first option is to use the useMemo
hook:
As a rule of thumb, useMemo
will only carry out the expensive calculation again if the value of arg
changes. This is only a rule of thumb though, since future versions of React may occasionally recalculate the memoized value.
The next two options are more reliable.
2. Passing functions to useState
The second option is to pass a function to useState
:
This function is only invoked on the first render. That’s super useful. (Though you need to remember that if you want to store an actual function in state, you have to wrap it inside of another function. Otherwise, you end up storing the function’s return value instead of the function itself.)
3. useRef
The third option is to use the useRef
hook:
This one is a bit weird, but it works and it’s officially sanctioned. useRef
returns a mutable ref object whose current
key points to the argument that useRef
is invoked with. This ref object will persist in subsequent renders. So if we set current
lazily like we do above, the expensive calculation is only carried out once.
Comparison
As you can see with my benchmark tool, all three of these options are just as performant as our initial class component. However, the behavior of useMemo
may change in the future. So if you want to have a guarantee that the expensive calculation is only carried out once, you should either use option 2, which passes a function to useState
, or option 3, which uses useRef
.
The choice between these two options comes down to whether you ever want to update the result of the expensive calculation. Think of the difference between option 2 and option 3 as analogous to the difference between storing something in this.state
or storing it in this
directly.