Yeah hooks are good, but have you tried faster React Components?

Seif Ghezala's photo
Seif Ghezala
Updated 2023-11-13 · 13 min
Table of contents
This article is not meant to perf-shame you. If you’re a beginner in React, don’t focus on performance issues but rather on getting comfortable and productive with it.

The performance of a React application can be defined by how long the user should wait before seeing the UI resulting from a certain interaction.

This article aims to point out some general performance issues in React applications with concrete examples and provide simple solutions to solve them. It includes changes related to the latest version of React (16.8.2) at this time, which comes in with support for Hooks.

When we talk about the performance of a React application, it’s inevitable to talk about how fast our components are rendering. Before jumping into tips that can improve the performance of these components, it’s fundamental to understand:

  1. What are we referring to when we say “rendering”?
  2. When does a render happen?
  3. What happens during a render?

About the render function

This is a rewrite, in my own words, of this very nice explanation of the "reconciliation" and "diffing" concepts in React.

Which render function?

If you write your component using a class, then by render function, I am referring to the render method of the class:

class Foo extends React.Component {
render() {
return <h1> Foo </h1>;
}
}

If you write your component as a function, then by render function, I am referring to the component function itself:

function Foo() {
return <h1> Foo </h1>;
}
Note: The examples in this article often use console.log to illustrate something. Make sure to open the browser's console to see the result of such interactions.

When does render happen?

The render function of a component Foo is called in 2 cases:

1- On state update:

a. State update in a class component that extends React.Component:

We have a count in the state that we increment whenever we click on the increment button until it reaches 10. When the count reaches 10, we just set the same value in the state.

I added a console.log statement that is printed every time the render function is called.

Notice how the render function is called whenever we trigger a state update, even when the value stops changing.

Therefore, in a class component that extends React.Component, the render function is called whenever a this.setState is triggered.

b. State update in a functional component:

Let’s implement the same component as a function, using the useState hook:

Notice how the render function is called whenever we trigger a state update but stops being called when the value doesn’t change anymore.

Therefore, in a functional component, the render function is called whenever the state value changes.

2. When the parent container re-renders:

Whenever we click the “Change name” button in the App component, it’s re-rendered. Notice how Foo is re-rendered as well regardless of its implementation.

Whether you’re implementing your component as a class component that extends React.Component, or as a functional component, the render function is called again whenever the parent container renders again.

What happens in the render function?

Two steps happen in order whenever the render function is called. It’s very important to understand both steps in order to know how to optimize a React application:

Diffing

In this step, React compares the new version of the tree returned by the new call of the render function with the old one. This step is necessary to React to decide on how to update the DOM. Although React performs this step using highly optimized algorithms, it still comes with a cost.

Reconciliation

Based on the result of diffing, React updates the DOM tree. This step has also a cost since unmounting and mounting nodes in the DOM doesn’t come for free.

Tip #1: Avoid unnecessary render calls by carefully distributing the state

Let’s consider the following example, where App renders two components:

  • CounterLabel, which receives a count value and a function to increment the count in the state of the parent App.
  • List, which receives a list of items.

Whenever the count in the state of the parent App is updated, both CounterLabel and List re-render.

It’s perfectly normal that CounterLabel is re-rendering here since the output of its render is based on the count value.

But in the case of List , it’s actually an unnecessary update since the output of its render is independent of any change in the count value. Although React won’t update the DOM in the reconciliation phase since it’s exactly the same one, it still performs work in the diffing phase to compare the previous tree with the new one.

To avoid such unnecessary diffing costs, we should consider placing certain state values lower in the tree hierarchy. In this case, placing the count value in the state of CounterLabel solves the problem.

Tip #2: Make fewer state updates

Since every state update leads to a new render call, having fewer state updates leads to fewer render calls 🤷‍♂.

React class components have a componentDidUpdate(prevProps, prevState) life-cycle method. It’s used to detect changes in the props or in the state. Although triggering a state update after a change in the props is sometimes necessary, you can always avoid state updates after a change in the state.

Let’s look at the following example:

We are rendering a range of numbers from 0 until a certain limit . Whenever the user changes the limit, we detect that in the componentDidUpdate and we set the new numbers list based on it.

The code works and achieves the requirements. However, it can be optimized.

For every change in the limit, we are triggering 2 state updates: the first one to change the limit and the second one to change the numbers. This leads to 2 renders per limit change:

// initial state
{ limit: 7, numbers: [0, 1, 2, 3, 4, 5, 6]
// change limit -> 4
render 1: { limit: 4, numbers: [0, 1, 2, 3, 4, 5, 6] } //
render 2: { limit: 4, numbers: [0, 2, 3]

Our logic generates the following problems:

  • We are triggering more state updates than needed.
  • We have inconsistent renders where the numbers don’t match the limit.

To improve this, we should avoid changing the numbers in a separate state update. Instead, we change them in the same state update:

Tip #3: Avoid unnecessary render calls with PureComponent and React.memo

We saw in the previous example that in some cases, placing certain state values lower in the tree hierarchy helps avoid unnecessary render calls. Unfortunately, that is not always possible.

Let’s consider the following example:

In this example, both Foo and Bar are re-rendered whenever the isFooVisibile value is changed in the state of their parent component App .

In this case, it’s necessary to place the isFooVisibile value in the state of App in order to decide on whether Foo should be rendered. Therefore, we can’t really move that value down in the state of Foo. Yet, it’s absolutely unnecessary to re-render Bar when that value changes, since the render output of Bar is independent of it. We only want to re-render Bar when its prop name changes, since its render output is dependent on it.

How do we solve this? 🤔

One way to solve this is to memoize Bar :

const Bar = React.memo(function Bar({ name }) {
return <h1>{name}</h1>;
});

This will make sure not to re-render Bar unless name changes its value!

You can also reach the same solution if the Bar component is a class by extending React.PureComponent instead of React.Component :

class Bar extends React.PureComponent {
render() {
return <h1>{name}</h1>;
}
}

Therefore, one way of avoiding unnecessary render calls of a component A, is to memoize that component by using React.memo if it’s a function or by extending React.PureComponent if it’s a class.

Should every component be a PureComponent or memo?

If the previous tip avoids us unnecessary re-renders, why don’t we just PureComponent for every class component and React.memo for every functional one? Why do we even have React.Component if there is already a better version of it? Why isn’t every functional component memoized by default?

Well, because these solutions are not always ideal.

Problem with nested objects

What do PureComponent and React.memo components do under the hood?

At every update (either state or the component above re-renders), they perform a shallow comparison between the keys and values of the new props and state, and the old ones. The shallow comparison is pretty much a strict equality check. If it detects a difference, a render call is performed:

// expected behavior with primitive values
shallowCompare({ name: "bar" }, { name: "bar" });
// output: true
shallowCompare({ name: "bar" }, { name: "bar1" });
// output: false
You can learn more about the default shallow function used in PureComponent and React.memo here.

Although this comparison works well for primitive values (such as strings, numbers, and booleans), it can lead to undesired behaviors with more complex values such as objects:

// unexpected behavior with primitive values
shallowCompare(
{ name: {first: 'John', last: 'Schilling'},
{first: 'John', last: 'Schilling'}
);
// output: false

Let’s revisit the previous example’s App render method and modify the prop we send to Bar:

You’ll notice that although Bar is memoized and its prop value is unchanged, it will still re-render every time the parent re-renders. This is due to the fact that the shallow comparison fails when comparing 2 objects that have the same value but different references.

Problem with function props

Props can also have functions. In JavaScript, functions are also stored as references and shallowly compared based on the values of these references.

Therefore, if we go back to the same example and pass an arrow function, Bar will still re-render every time its parent does.

Tip #4: Writing better props

One way we can solve the previous problem is by writing our props differently.

Instead of passing an object, we can break down that object into primitive values:

<Bar firstName="John" lastName="Schilling" />

Instead of passing arrow functions, we can pass a function whose declaration happens only once. Thus, it will always have the same reference value. An example of this is a class method:

class App extends React.Component {
constructor(props) {
this.doSomethingMethod = this.doSomethingMethod.bind(this);
}
doSomethingMethod() {
// do something
}
render() {
return <Bar onSomething={this.doSomethingMethod} />;
}
}

Tip #5: Controlling the update

Tip #3 solves the unnecessary update problem. Unfortunately, we can’t always apply it.

In some cases, we just can’t break down the object. Imagine if we were passing some complex data structure (e.g. a tree nested with children). We simply couldn’t break it down.

Moreover, we can’t always pass functions that are only declared once. In our example, it would be impossible to do so if App was a function component.

Fortunately, we can control the shallow comparison that happens in a class component or in a memoized functional one 🎉!

In a class component, we can implement the method shouldComponentUpdate(prevProps, prevState) which returns a boolean. Only when the return value is true, a render will occur.

If we’re using React.memo, we can pass a comparison function as the second argument.

The comparison method passed to React.memo works the opposite of the shouldComponentUpdate method. When the comparison method passed to React.memo returns false, a re-render happens.
const Bar = React.memo(
function Bar({ name: { first, last } }) {
console.log("update");
return (
<h1>
{first} {last}
</h1>
);
},
(prevProps, newProps) =>
prevProps.name.first === newProps.name.first &&
prevProps.name.last === newProps.name.last
);

Although this tip works, you should be careful with the cost of the comparison function you provide. Deeply comparing huge props objects will cost you in terms of performance.

Recent articles

Guide to fast Next.js

Insights into how Tinloof measures website speed with best practices to make faster websites.
Seif Ghezala's photo
Seif Ghezala
2024-01-15 · 13 min