Optimizing React Performance using keys, component life cycle, and performance tools | Part 1
Recently, I was given the amazing opportunity to speak at the SoCal ReactJS meetup. This post is going to summarize the topics that I touched on.
Before we can even begin talking about understanding React performance, we have to understand what reconciliation is. Reconciliation is the process of finding the minimum number of changes that must be made in order to make the virtual DOM tree and the actual DOM tree identical. React actually has a fantastic page on reconciliation that goes very in depth. But you can imagine the process to look similar to this image:
where the blue colored nodes represent changes or new edits made to virtual DOM on render. In general, this process of making two trees identical is a very expensive operation. Even after many iterations and optimizations, this remains a very difficult and time-consuming problem.
### Increasing performance
React already amortizes reconciliation by relying on assumptions about developer behavior that allow for a much faster algorithm in practical use cases. Some of these assumptions involve:
- Node types
- Custom components
React also provides developers with a component life cycle method called shouldComponentUpdate() that can drastically improve performance. But today, we’re only going to be talking about keys and shouldComponentUpdate().
### Keys identify elements
Keys are extremely important when considering list-wise differences between siblings in the two trees. For example, in the tree above, list-wise differences are calculated when the bottom children are being compared to each other.
This can be an arduous task, especially when considering that nodes can be inserted, deleted, substituted, AND moved. But with keys, every node can have a key that identifies it amongst its siblings.
These unique identifiers are used to store nodes in a sort of hashtable allowing for fast lookups between trees when finding insertions, deletions, substitutions, and moves.
### How to choose a good key
As you’ve probably noticed, keys are very similar to hashes in that keys must be unique and deterministic. What this means is that keys must be unique amongst its siblings and based on the value of the node it is mapped to. You should be able to find the key to a node based on the contents of that node and also determine which node it is based on the key. Some examples of keys are:
- Random key
- Index key
- ID key
The random key satisfies the unique paradigm but is not deterministic because it is a random number that is generated on every render, meaning the same node will have a different random key on every rerender. The index key is actually provided by React and is the default if you don’t specify a key in your component. This generally works in a lot of cases, but there are a few instances that we’ll go into later where index doesn’t function well. The final key is a key based on identity that will statisfy both the unique and deterministic requirements.
### Demo Page
In order to see these different types of keys in action and observe their impacts on performance, I assembled a demo page that renders 500 images of red pandas using flickr’s API.
The layout of the page is very simple with four components, the View, Header, Feedlist, and Feeditems. The component hierarchy is fairly straightforward.
Each of the Feeditems, or photos, has a remove button that when clicked will remove that photo from the Feedlist, hence that specific Feeditem component won’t be rendered. Because the view is interacting with user inputs, there has to be state. In the layout, the View component is highest on the tree making it the parent-most component. Thus, in order to minimize the amount of stateful components, only the View component was stateful, allowing for changes to be passed as props to the other components, accordingly. My code for the view component is as follows
But what is most important is the remove functionality.
This custom funciton is passed down as a handler to each Feeditem and is executed when the remove button is clicked. On user input, the photo at the given index will be spliced out of the items array and state is reset, triggering a rerender with the modified array of photos.
In order to change keys and test the performance of each type of key, the Feedlist component must be edited and it’s as simple as changing the key property of each Feeditem.
### Performance tools
To really see the impact that each type of key has on performance, we can use React’s provided performance tools. These tools will allow us to quantify performance effects and give us insight as to what is happenning behind the scenes. Some of the inbuilt functions given to us are
To see what each of these functions do you can look at React’s Perf tools page. For now, we’re just going to use Perf.printDOM() to see DOM manipulations made. Using these tools are as simple as running these lines of code.
### Random keys
The first type of key that we’re going to test is the random key. To use a random key, all we have to do is change the key property in the Feeditem component to Math.random() inside of our Feedlist.
Now, if I run the demo and start measuring with Perf.start(), when I click the remove button, I can see the DOM manipulations with Perf.printDOM(). The table I get back looks a little like this
The table was so large, I had to cut it in half just to fit it reasonably. Looking at the DOM manipulations made, we can see a lot of set innerHTMLs and removes and the total time is ~300ms. But this doesn’t really make sense becuase all that was supposed to happen was a single Feeditem/photo was supposed to be deleted. To understand what’s going on, we have to map out what’s happenning behind the scenes during reconciliation.
We can consider the old section as the Feeditems in the actual DOM and the new section as the Feeditems in the updated virtual DOM. If we look at the keys, we can see that when the rerender happens, new random keys are given to the Feeditems by Math.random(). Thus, when reconciliation occurs, the B, C, and D nodes are going to be considered as new DOM nodes that need to be created, and the old B, C, and D nodes are going to be deleted because their corresponding keys can’t be found in the virtual DOM.
But this doesn’t make sense at all because the only edit that should be happening is the removal of a single Feeditem, or in this case, the A node. A lot of time is being wasted on deleting and appending the same photos. This is exactly why it is a really bad idea to use Math.random() as your key. But what about a key that React defaults to?
### Index key
When a key isn’t specified as a property, React automatically sets the key to be the components index or position. In order to test this, we just have to edit the key to be index.
When we run the demo, we see this
Once again, a gigantic table is printed, but this time, instead of creates and deletes, we see updates, a single remove at the end of the table and a total time of ~180ms. Although this is much faster than the random key, 180ms for removing a single photo is pretty ridiculous. To understand why there are so many additional DOM manipulations, we have to understand how nodes are being reconciled.
If the key is based on index, rather than the key being tied to the node, it is tied to the position of the node. So any operations that edit position, such as removing the first item in an array, will throw the reconciliation process off. All of the items following the first removed item will be shifted up one index, resulting in a different key upon rerender. Thus, when reconciling, the wrong nodes will be compared, which would explain why all of those updates are happenning as well as why there’s a single remove at the end of the table.
### ID key
Lastly, we consider a key that is based on identity. Flickr’s API is amazing and provides a unique ID for every single photo, so we’re going to use that ID as key.
Now if we run the demo, we get this table
There’s only a single remove and this makes perfect sense. But, the total time is still ~110ms, which is lower than before but still pretty ridiculous for a single DOM manipulation. To conceptualize what’s going on, we can map out the reconciliation
Because, the ID’s are unique and tied to a single photo, even with a different rerender, the ID remains the same and so does the key. Now, the correct nodes are being compared to each other.
So where is all this extra time being spent? Well, even though the correct nodes are being compared, conceptually, there’s no reason for the comparison. We know that the remaining photos are going to remain the same; removing a single photo won’t affect the remaining photos, so why waste time on reconciling the other photos? This time spent is called wasted time.
Wasted time is spent on components that didn’t actually render anything, e.g. the render stayed the same, so the DOM wasn’t touched
### How can we see where time is being wasted?
React actually provides with a wonderful function that was mentioned earlier
If we run this on our demo, it produces this table
There are 499 instances of wasted time, and of the total time, almost 90% is wasted time!
### How can we minimize wasted time?
If you remember, earlier, I mentioned a component lifecycle method called shouldComponentUpdate.
- Triggered before re-rendering process
- Gives the developer the ability to short circuit reconciliation
- Defaulted to true
We can see how shouldComponentUpdate functions by taking a look at some example trees
In this example tree, based on the legend, we can see which nodes require reconciliation (a change occurred making the node ‘dirty’) and which nodes returned true/false for shouldComponentUpdate. But C8 is what we want to especially focus on.
What’s happening is that for C8, shouldComponentUpdate is returning true, meaning we’re telling React that this node has changed and must be updated, but no reconciliation is actually needed. The time spent rendering C8 is wasted time. However, a good use of shouldComponentUpdate would be C2.
Because shouldComponentUpdate returns false for C2, none of C2’s children have to rendered and the node itself doesn’t have to be reconciled. This cuts back on wasted time and allows us to actually skip over and short circuit the reconciliation process for this node. However, if you remember the bullet points earlier about shouldComponentUpdate, you’ll notice that shouldComponentUpdate is defaulted to true. In order to change this, we have to implement our own shouldComponentUpdate.
If we edit the Feeditem component and add the shouldComponentUpdate method, we can customize it to return true only when the photos are different (id’s are different).
Now, if we run the demo and printWasted/blog.
No wasted time! And the total time is only 30ms! This is amazing! Just by changing the keys and implementing a shouldComponentUpdate, we were able to bring the time from ~300ms down to 30ms. Now, behind the scenes what’s happening is a single node is removed but because shouldComponentUpdate returns false, no time will be spent comparing the remaining photos!
“With great power comes great responsibility”
- Uncle Ben
shouldComponentUpdate gives us, the developers, a lot of power, by allowing us to bypass reconciliation but it also adds a degree of complexity. If we don’t fully understand the process, we could end up skipping over nodes that actually need to be reconciled, resulting in inconsistencies in the expected output.
In Part 2, we are going look at some of these inconsistencies by implementing a star function that allows users to star certain photos.