Lawrence Eagles Senior full-stack developer, writer, and instructor.

Debugging React applications with the React Profiler API and the Profiler DevTool

7 min read 2051

Debugging React applications with the React profiler component and the profiler DevTool

Prerequisites

Some knowledge of React is required to get the most out of this tutorial. Also, knowledge of performance optimization in React and component level memoization using React.memo is a plus.

The tools to measure performance

There are several tools to measure performance optimization in React. But in this post, our focus is on the React profiler DevTool and the React Profiler API.

Before moving forward, it is important to note that performance is a tradeoff. Consequently, you can end up with a slower application after optimization.
It is important you use tools such as the React Profiler DevTool and the React Profiler API to measure the success/failure of the applied techniques.

Performance optimization by memoization

In this section and the next, I will elaborate on performance optimization, using a specific technique called memoization. Then I’ll apply it to an example application and measure its effect using both the React profiler DevTool and the React Profiler API.

What is memoization?

In computing, memoization is an optimization technique used primarily to speed up computer programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again.
Wikipedia

An application performs several, expensive, long-running computations throughout its lifecycle. The idea of memoization is to handle these computations in a pure function. All dependencies needed for these computations are passed as arguments, and when the function returns, its value is cached. Whenever the function is called with the same arguments, the cached value is returned. This means no unnecessary function calls, consequently, time-consuming long-running I/O can occur in an instant. The performance benefit of this is tremendous.

Although memoization is great, there is a downside to it. The size of our cache grows as we continue to store results for different dependencies. In a nutshell, we are trading space for speed.

I have given an overview of memoization, but how does this apply to React?

Memoization in React

There are multiple ways to apply memoization in a React application. React gives us the useMemo and the useCallback Hook for this purpose. Libraries like Reselect use memoization under the hood and utility libraries like lodash have a _.memoize function.

In this tutorial, we will work with the React.memo. This function takes a React functional component as an argument. It memoizes its current rendered output and skips unnecessary re-renders, thus it will only rerender the component if its props change.

Before jumping into any of these, let’s call to mind our performance rule of thumb.

We made a custom demo for .
No really. Click here to check it out.

load time from computed data and long running I/O

The diagram above shows how a React app renders its contents. We see that a loading component is displayed while the application performs a long-running computation. And when this finishes, the content is rendered to the user. We can also see that the load-time is the time taken to perform the long-running computation and render the appropriate content.

Long-running computations (I/O) are operations that require a lot of time and resources. It could be file upload, API calls (request for all the images in a gallery), a complex calculation, etc.

In performance optimization, the aim is to measure the load time of each component, identify the components that are causing performance problems, and fix them. I will be elaborating on these in the next section with a sample application.

Measuring performance optimization

To get the most out of the section, you should clone the repository and have your local setup by following these steps.

  • Clone the repository:
git clone https://github.com/lawrenceagles/profiler-demo
  • Change to application directory:
cd profiler-demo
  • Run the app:
yarn start or npm start

Kindly consider the code below:

import "./App.css";
import React, { useEffect, useState } from "react";
// component
import Languages from "./components/Languages";
function App () {
    let topLang = [ "JavaScript", "Python", "Rust", "TypeScript", "C++", "C" ]
    const [ languages, setLanaguages ] = useState([]);
    const [ count, setCount ] = useState(0);
    const dummyAPICall = () => setTimeout(() => setLanaguages(topLang), 5000)

    useEffect(() => {
        dummyAPICall();
    }, []);
    return (
        <div className='App'>
            <header className='App-header'>
                {languages.length === 0 && <h2>Welcome!</h2>}
                {languages.length ? (
                    <Languages languages={languages} />
                ) : (
                    <div className='text-warning text-center m-4'>
                        Loading languages...
                    </div>
                )}
                <hr />
                <h4>{count}</h4>
                <button
                    className='mb-3 btn btn-primary'
                    onClick={() => setCount((count) => count + 1)}
                >
                    Add count
                </button>
            </header>
        </div>
    );
}

export default App;

The code above is just a small portion of the app. It simulates a long-running computation by calling the dummyAPICall function when the component mounts. When this finishes, the language state is updated, and its value is passed as props to the language component. Both of these triggers a re-render as expected. And a list of the languages is displayed.

Also, we have a button that is used to simulate user interactivity with our app. When clicked, it updates the count state and expectedly, this triggers a render update. Since this event does not affect the language state or component, the language component should not be re-rendered, right? Well, it gets re-rendered and that is the problem.

From the implementation below:

import React from "react";
import PropTypes from "prop-types";
const Languages = ({ languages }) => {
    return (
        <div>
            <h3>Top 6 Languages:</h3>
            {(console.log("I am mounting!"),
              languages.map((language, index) => <p key={index}> {language} </p>))
            }
        </div>
    );
};
Languages.propTypes = {
    languages : PropTypes.array.isRequired,
};
export default Languages;

Here, we see that the language component receives a languages prop (an array), maps through it, and renders each array item. I have added a console.log() statement. This is to allow us to track how many times this component gets re-rendered. It is not the most adept tool, but it is quite handy.

Consider the image below:

Message in console indicating component was mounted 15 times

We can see that the language component gets re-rendered as many times as the button was clicked (15 times in this case). Remember, the button is used to simulate real-life UI components like date picker, comment box, etc. This means our language component will be re-rendered when a user selects a date, makes a comment, or interacts with our UI.

Operations like these are expensive in a real-world scenario, so we must collect timing information, to measure the cost of each render. To do this, we will employ the React Profiler DevTool.

The Profiler DevTool

The profiler DevTool plugin was introduced in React 16.5. It uses the React profiler API under the hood to collect timing information of each rendered component. This makes it a nifty tool for identifying performance bottlenecks in React applications. To profile your component using the profiler DevTool follow these steps:

  • Open your console and click on the profiler tab

profiler tab

  • I have labeled three important items in this image, the first (1) is the profiler tab, and the second (2) is the record button, and the third (3) is the reload button
  • To start profiling, we can either click on the record or reload button. Here we will use the reload because we want to capture the mount phase and all renders in our profiling
  • Click on the reload button and allow the long-running computation to finish. When the language component is rendered, click on the button several times, then click on the record button to stop profiling. You will get something similar to this:

reload button

Notice the timeline section that displays an array of bars with the first two being the tallest, followed by six green colored ones with the same height.

The first represents the mount phase of the App component, so it took the most time to render. We can see the render duration was 6ms. Mounting is more expensive than subsequent re-renders because it is a DOM required change. Subsequent re-renders only change some values in the DOM and do not create a new DOM node.

counter with process shown in console

The six green bars represent the six re-renders resulting from the click event. Clicking each bar shows the render duration and more commit information.

Although most of the renders for our app occurs in milliseconds, it is not so in a real-world situation. The render duration can easily become very high, and this can drastically affect our app’s performance. The entire purpose of this example is to show you how to collect performance information to identify performance bottlenecks. Also, more performance information can be found at Flamegraph and ranked chat.

One very important piece of information we need is to know the state of the props during each render. For this, you can click on any of the bars in the timeline and click on the components tab. This gives you the components props during that render.

languages props in console

From the image above, we can see that the languages component prop did not change during each re-render. This makes each re-render unnecessary and a lot more costly, consequently, this is an area where we need to improve our app.

Applying memoization for performance optimization

Since we have been able to identify the need for optimization in our app, and we have a way to measure our input, we can proceed to optimize our app.

Remember that in memoization, if the input (in this case, the props) does not change, a cached value is returned, thus improving performance. For this, we can use React.memo to memoize the languages component so that it does not re-render if the props do not change.

So the Languages component can be re-implemented like this:

import React, {memo} from "react";
import PropTypes from "prop-types";
const Languages = ({ languages }) => {
    return (
        <div>
            <h3>Top 6 Languages:</h3>
            {(console.log("I am mounting!"),
              languages.map((language, index) => <p key={index}> {language} </p>))
            }
        </div>
    );
};
Languages.propTypes = {
    languages : PropTypes.array.isRequired,
};
export default memo(Languages);

Right now, we are exporting the memoized languages component, and this would only be re-rendered if the props are passed to it.

We can now start profiling our components again to measure our efficiency. Consider the image below.

languages did not render message

From the image above, we see that during each render phase from the timeline, the languages component did not render. Thus once the languages component mounts it only gets re-rendered when its props change. This eliminates the cost of unnecessary rendering, consequently improving our app’s performance.

If you don’t want to deal with the React DevTool you can use the React profiler API directly.

The Profiler component

The Profiler API is a component that enables us to collect performance information to measure the cost of rendering:

import "./App.css";
import React, { useEffect, useState, Profiler } from "react";
// component
import Languages from "./components/Languages";
function App () {
    let topLang = [ "JavaScript", "Python", "Rust", "TypeScript", "C++", "C" ]
    const [ languages, setLanaguages ] = useState([]);
    const [ count, setCount ] = useState(0);
    const dummyAPICall = () => setTimeout(() => setLanaguages(topLang), 5000)

    useEffect(() => {
        dummyAPICall();
    }, []);
    return (
        <div className='App'>
            <header className='App-header'>
                {languages.length === 0 && <h2>Welcome!</h2>}
                {languages.length ? (
                    <Profiler
                      id="language"
                      onRender={
                          (id, phase, actualDuration) => {
                          console.log({id, phase, actualDuration})
                          }
                      }>
                      <Languages languages={languages} />
                    </Profiler>
                ) : (
                    <div className='text-warning text-center m-4'>
                        Loading languages...
                    </div>
                )}
                <hr />
                <h4>{count}</h4>
                <button
                    className='mb-3 btn btn-primary'
                    onClick={() => setCount((count) => count + 1)}
                >
                    Add count
                </button>
            </header>
        </div>
    );
}

export default App;

Above we have implemented the Profiler API by simply importing it and wrapping our language component with it. It takes two props and they are:

  1. The ID, which is a string
  2. The onRender callback. This callback takes a number of arguments but the first three (id, phase, and actualDuration) are required

In this onRender function, we log and object with these three arguments to the console. And this gives us all the performance information we need as seen in the image below:

language in mount phaseFinal thoughts

The React Profiler component and the React Profiler DevTool are both amazing and can be used together. You can get more details on the React Profiler API here.

It is not recommended to use the React Profiler API in production. However, if you need it badly, you can get instructions on how to use it in production here. I hope that this post will change your approach to performance optimization for the better.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Lawrence Eagles Senior full-stack developer, writer, and instructor.

Leave a Reply