Optimizing React Re-Renders for Improved Performance

Hello, this is Sahil Khokhar, and I work as a Frontend Architect at Mercari. This post is for Day 20 of Mercari Advent Calendar 2022.

You’ve probably heard of React, right? It’s that popular JavaScript framework that a lot of people use to build web apps. Well, we use it too! At Mercari, we use React for most of our products because it’s declarative, flexible, and performs really well. These characteristics make it easy to understand and maintain code, as well as streamline the development process with reusable components.

Okay, let’s break down some technical details…

If you’re familiar with React, you probably know that it uses a virtual Document Object Model (or "DOM") to optimize updates to the actual DOM. This process of updating the DOM is also called DOM manipulation, and it’s at the heart of web development. The problem is that DOM manipulation can be really inefficient and slow. That’s where React comes in – it uses something called the Virtual DOM to make DOM manipulation more efficient and improve performance. When you update a React component’s state, the Virtual DOM compares the new version to the old version (kind of like taking two snapshots and finding the difference) and only makes the necessary changes to the actual DOM. This helps improve the overall performance of the application.

Now that we know a little bit about what’s going on under the hood in React, on the surface its top priority is to make sure the user interface stays "in sync" with the application state. To do this, React triggers re-renders for components as needed. It’ll do whatever it takes to make sure the UI stays up to date. In this blog post, I want to clear up some misconceptions about re-rendering and show you one of the simple ways to optimize the rendering performance of your react applications.

Understanding React Re-Renders

If you’ve struggled with understanding why a particular component re-renders multiple times or why renders are super slow, you are not alone! In fact, we have all spoken with numerous React developers who have struggled to fully grasp the underlying logic of re-rendering in their applications.

In an effort to clarify this concept, let us attempt to simplify it here…

Re-renders in react are triggered with a state change.

It’s the only way how re-renders are started. Let’s take a look at this with an example:

Basic App component code

The above example demonstrates the use of a single tree with a component called App. This component displays a button labeled "Increment" and shows the current count value below it. When the user clicks the "Increment" button, the count is incremented by 1.

In React, each component instance has a corresponding state. In the above example, the App component has a single piece of state called count. When the count state changes (such as when the user clicks the "Increment" button), the App component will re-render to display the updated count. This process is a fundamental aspect of React’s functionality and is pretty straightforward.

In the event that our component hierarchy is more complex, this can introduce additional complexity.

In React, when a component re-renders, all of its descendant components are also re-rendered.

To illustrate this concept, let’s consider the following example:

Abstracted code

In the above example, there are three components: App, Counter, and CountNumber. App is the top-level component and renders Counter, which in turn renders CountNumber. In terms of hierarchy, CountNumber is the immediate descendant of Counter, which is the immediate descendant of App.

The Counter component has a state called count associated with it. When the value of count changes, the Counter component will re-render and, since CountNumber is a descendant of Counter, it will also re-render.

It is a common misconception that a change in state within a descendant component will trigger a re-render of the parent component in React.

However, this is not the case.

React follows a one-way data flow, meaning that data can only flow downward through the component hierarchy and cannot flow upwards. Therefore, a change in state within a descendant component will not trigger a re-render of the parent component.

So, when the count state changes in the above example, the App component is not re-rendered.

The process of updating the virtual DOM to match the desired state of the UI in React is known as Reconciliation. This process occurs after a state change and involves calculating the difference, or "diff", between the virtual DOM and the desired state. The virtual DOM is then updated with the minimum number of operations necessary to bring it in line with the desired state. This helps to ensure that the React UI stays in-sync with state changes and helps to optimize the performance of the application by minimizing the number of DOM manipulations (remember we talked about React’s #1 priority in the beginning of the article).

React reconciliation image

Optimizing Rendering Performance

It is important to carefully consider the data flow through the component hierarchy and the proper association of state with component instances in React to avoid potential pitfalls. Incorrectly managing data flow and state association can lead to unintended re-renders and potentially negatively impact the performance of the application.

Now, let’s explore some examples of potential pitfalls in React rendering and how we can optimize rendering speed:

Non abstracted code with Expensive component

The above example consists of three components: App, Counter, and ExpensiveComponent. The App component is the top-level component and renders the Counter component, which in turn renders the ExpensiveComponent.

Imagine that the ExpensiveComponent performs an expensive logical operation. This means that whenever the Counter component is rendered or re-rendered, due to its association with the count state, ExpensiveComponent will also be re-rendered, even though it has no relation to the count state.

It may be tempting to use React.memo to memoize the ExpensiveComponent and prevent it from re-rendering. While this can be a viable solution in some cases, it may not always be the most effective approach. Memoizing every component can be counter-productive if the component has a large number of props and few descendants, as the process of checking the props for changes may be slower than simply re-rendering the component. In such cases, making the ExpensiveComponent pure with React.memo may cause it to unnecessarily check its props on every render, leading to decreased performance.

So is there a way to optimize the performance of this code?

Yes, one option is to optimize the component hierarchy by moving the state down and properly abstracting it. If we closely examine the above example, we can see that only a specific portion of the Counter component depends on the count state. By extracting this code into a separate component and moving the count state down into it, we can potentially improve performance.

Abstracted code to improve performance

Now, whenever re-renders are triggered for the DisplayButtonAndCount component, ExpensiveComponent is not re-rendered. Problem solved.

With the above smart code abstraction, it not only makes our code more readable but also improves the performance of our application (cherry on top for doing effective abstraction in your codebase).

What’s the moral?

The simple trick and concepts covered above have had a significant impact on the industry, such as reported by Brooks Lybrand:

Performance optimization image

The concept of effectively managing state and maintaining a proper component hierarchy is a fundamental aspect of writing high-quality code in React.

It is important to place state variables where they are used and to avoid premature optimization. As a general rule, it is advisable to write the code first, measure its performance, and only optimize if necessary. Simple techniques, such as above, that address these issues are often undervalued and deserve more attention. Rigid adherence to certain paradigms can result in micro-optimizations that may have only a minor impact on performance but significantly decrease code readability and organization.

Thank you for reading!

This article is motivated from blog-posts by Kent and Dan. Be sure to check those out too for more interesting insights.

Tomorrow’s article will be about the Kubernetes Scheduler by @sanposhiho. Look forward to it!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加