In smaller projects, React offers snappy performance – the virtual DOM diffing means updates can happen quickly and for the most part, things just work. But, particularly when you’re dealing with large data sets, things can bog down as every render loop causes many unnecessary renders of components which have not actually changed.
React Dev Tools comes with a profiler that can help pin down the problem, and React 16.8 came with some features to make it easier to improve performance of functional components. But to use these tools effectively you need to know what the problem is and how to find the culprit.
I recently ran into an issue where a page took about 60ms to render every character the user typed, which was a totally unusable experience. After a bit of digging through the profiler tool and making some small tweaks to the codebase, I got rendering down to about 4ms, where it should be. While I can’t share the actual code with you, I’ve put together a simpler version to illustrate the problem and the steps I took to resolve it.
In our example, we will display a big grid of 1000 numbers, where the numbers are determined by a
number prop passed into the component. Whenever that
number props changes,
BigGrid has to re-render itself. With 1000 elements, this takes a while. As well, there’s also a text input that doesn’t have anything to do with the
BigGrid. This is a pretty contrived example, but it’s not uncommon to have components on a page that take a bunch of data and have to do an expensive re-render under certain circumstances.
If you’d like to follow along, the code for this step is on this branch.
If you’re working on this project and you can’t get rid of the
BigGrid (product requirements, amiright?), you have an issue. Typing in the name text field is unusably slow, even though its value doesn’t affect the grid. What’s going on here?
The first step is to install react dev tools if you haven’t already, and go to the Profiler tab.
Next, you can click the Start Recording button, enter a single character, wait for it to render, then click Stop Recording. After a moment we’ll get a graph that shows every component that rendered, and how long it took. That render time includes time to render children, so what we’re looking for is a component that has a big gap between the time to render its children, and the total time to render itself.
The whole render loop is taking 157ms, which is way too long. And as you may have guessed,
BigGrid is the culprit – it’s rerendering, even though none of its props have changed. That’s because
BigGrid is a functional component. Unlike class components, a functional component re-renders every time its parent re-renders. That’s like how a normal function is called every time it’s called inside another function, even if the arguments happen to be the same.
Class components work differently, as a
React.Component will only re-render when its props change. For the most part it doesn’t matter – renders are fast, and the simplicity of functional components is worth the extra renders. But for situations where you want functional components to act more like classes, React 16.8 introduced
React.memo is a higher-order component that takes a component (like
BigGrid), and returns one that only re-renders when the props change. In other words, it
memoizes the component – though it only remembers the last set of props, so memory usage doesn’t grow over time. Using it is as simple as wrapping the component’s export.
When we re-run the profiler, we can see it worked! The BigGrid is grayed out to show that it didn’t re-render, and the render triggered by typing a character is now down to 4ms. That’s almost a 40x performance improvement! Not bad for a line of code.
Here’s the code at this point, if you’re following along.
There’s one more thing you’ll need to keep in mind when memoizing a component: object references. To stay fast, useMemo only does a reference equality check on the props. If you send in a new array or object, even if its values are the same, the reference will be different and the child component will re-render. This is mostly a problem when you’re creating objects in the render loop of the parent, and can be a hard problem to track down.
As an example, let’s say that BigGrid has a prop that could be a list, instead of a single primitive value, like this.
A natural thing to do would be to construct that array in the parent component’s render loop, like so:
But bad news! Now the component is rendering when it shouldn’t again, even though it’s memoized, because
[firstNumber, secondNumber] returns a different array reference in every render loop.
Here’s the code if you want to play with this problem yourself.
There are a few solutions here, depending on where the prop is coming from.
The useMemo hook is a new React 16.8 tool built for this situation. You pass it some dependencies, and it’ll only return a new reference when the dependent properties change. This should be your go-to solution.
Keep the object in state
If you’re using an earlier version of React, you can do the work of useMemo manually. Instead of re-calculating the array every render, you could re-calculate it only when one of the input fields change, and store the calculated array in local state. This solves the problem, but can require some major re-work.
If the reference that’s changing is a function instead of an array or other object, then useCallback works just like useMemo – it only returns a new reference to the callback when the associated data changes.
Finally, if your data is coming from Redux instead of local state, then reselect is a great tool for making memoized selectors. Redux automatically memoizes connected components, so reselect can prevent
mapStateToPropsfrom triggering unnecessary re-renders. See their docs for more information.
In this case, useMemo will do the job nicely. We can just replace
Now, when the form component renders,
useMemo will check if its dependencies (
secondNumber) have changed. If they haven’t, it returns a reference to the same array it rendered in the previous render loop. Only when the numbers change does it return a new array, which triggers a new render of the
Running the profiler one more time, performance is now back to where it should be.
The final, optimized version of the code is here.
So that went well! But before you scatter
React.useMemo everywhere, remember that there’s a tradeoff – it takes time to do these props comparisons and memory to memoize a component. As with all performance optimizations, you should only
useMemo when there’s a real problem.
But if the profiler tool shows you a component taking a long time to render AND that component is rendering when it doesn’t need to then it’s probably time to memoize something!
Interested in more software development tips and insights? Visit the development section on our blog!