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
.
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.
import React from "react"; | |
const BigGrid = ({ number }) => { | |
… | |
}; | |
export default React.memo(BigGrid); |
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.
const BigGrid = ({ numbers }) => { | |
… | |
}; |
A natural thing to do would be to construct that array in the parent component’s render loop, like so:
<BigGrid numbers={[firstNumber, secondNumber]} /> |
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.
mapStateToProps
from triggering unnecessary re-renders. See their docs for more information.In this case, useMemo will do the job nicely. We can just replace
<BigGrid numbers={[firstNumber, secondNumber]} /> |
with
const numbers = React.useMemo(() => [firstNumber, secondNumber], [firstNumber, secondNumber]); | |
<BigGrid numbers={numbers} /> |
Now, when the form component renders, useMemo
will check if its dependencies (firstNumber
and 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 BigGrid
.
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!
Will Ockelmann-Wagner is a software developer at Carbon Five. He’s into functional programming and testable code.