Optimizing performance in a React app – Latest Guide with Examples

Spread the love

React is a UI framework that stands out for its exceptional rendering performance. However, even with its renowned virtual DOM efficiently rendering components, it’s possible to encounter performance problems when dealing with medium to large web applications.

 

In this guide, we will discuss some essential strategies for enhancing the performance of a React application, including pre-optimization approaches.

 

Understanding how React updates its UI

 

In order to optimize a React application, it’s important to know how React updates its user interface and how to measure the app’s performance. This knowledge will help us to effectively troubleshoot any performance issues that arise. Let’s begin by reviewing the process of React UI updates.

 

When a component is rendered, React creates a virtual DOM for its element tree within the component. Whenever the component’s state changes, React recreates the virtual DOM tree and compares it to the previous rendering.It will only update the changed element in the actual DOM through diffing.

 

Examining diffing and re-rendering

 

React uses the concept of a virtual DOM to reduce the performance cost of updating a webpage by avoiding direct manipulation of the actual DOM, leading to faster UI rendering times. However, if not managed well, this can lead to a slowdown in a complex application. When a state change occurs in a React component, it triggers a re-render, and when the state is passed down to a child component as a prop, the child component is also re-rendered, which is necessary to update the UI.

 

However, the problem arises when the state change does not affect the child components, meaning they do not receive any prop from the parent component. Despite this, React still re-renders these child components. Hence, if the parent component re-renders, all of its child components will re-render by default, regardless of whether they receive a prop or not.

 

To illustrate this concept, consider an App component that contains a state and a child component.

 

//App.js

import ./styles.css“;

import { useState } from react“;

export default function App() {

 const [input, setInput] = useState(“”);

 return (

   <div>

     <input

       type=text

       value={input}

       onChange={(e) => setInput(e.target.value)}

     />

     <h3>Input text: {input}</h3>

     <ChildComponent />

   </div>

 );

}

function ChildComponent() {

 console.log(“child component is rendering“);

 return <div>This is child component.</div>;

};

 

Whenever the state of the App component updates, the ChildComponent re-renders even when it is not directly affected by the state change.  

 

Generally, the act of re-rendering should not result in any performance problems, and we should not experience any delays in our application. Nevertheless, if a particular component that is not affected by the re-rendering involves complex computations, which lead to noticeable performance issues, it becomes necessary to optimize our React application. This leads us to the second technique for pre-optimization, which is profiling.

 

Profiling the React app to locate bottlenecks

 

Using React, we can evaluate the efficiency of our applications by utilizing the profiler available in the React Developer Tools. This tool allows us to gather valuable performance data every time our application is rendered. The profiler captures various aspects such as the time it takes for a component to render and the reasons why a component is rendering. By analyzing this information, we can investigate and optimize the affected component as necessary.

 

To access the profiler, first, install the React DevTools for your preferred web browser. If you don’t already have it installed, you can visit the extension page and install it (available for Chrome and Firefox). Once installed, the profiler tab will become available when working on a React project. 

 

The React DevTools profiler provides a detailed overview of every component that gets rendered when the input text field updates. The profiler presents a flame chart that displays the time it took for each component to render and the reason behind why the App component is rendering. This way, we can observe and analyze the rendering process of each component in great detail.

 

Likewise, the image below shows the child component is rendering because the parent component rendered:

 

It can affect the performance of the React application, when a time-consuming operation is present in a child component. This leads us to explore optimization techniques.

 

React performance optimization techniques

Keeping component state local where necessary

 

As we know that updating the state of a parent component triggers a re-render of both the parent and its child components,we can optimize the rendering process by extracting the relevant portion of the code that cares about to the component’s state.

This approach ensures that a component is re-rendered only when required.

 

By refactoring our earlier code, we have the following:

 

import { useState } from “react”;

 

export default function App() {

  return (

    <div>

      <FormInput />

      <ChildComponent />

    </div>

  );

}

 

function FormInput() {

  const [input, setInput] = useState(“”);

 

  return (

    <div>

      <input

        type=”text”

        value={input}

        onChange={(e) => setInput(e.target.value)}

      />

      <h3>Input text: {input}</h3>

    </div>

  );

}

 

function ChildComponent() {

  console.log(“child component is rendering”);

  return <div>This is child component.</div>;

}

 

By extracting the state and input related to the FormInput component and making it a sibling to the ChildComponent, we ensure that only the FormInput component is re-rendered when the state changes. This improves the performance of our React app significantly. As a result, the ChildComponent no longer re-renders on every keystroke, which can be observed by testing the app in our demo.

 

But some times, it may be necessary to include a state in a global component and pass it down to child components as a prop. When this occurs, it is important to understand how to prevent the unaffected child components from being re-rendered.

 

Memoizing React components to prevent unnecessary re-renders

 

Unlike the previous performance technique, where refactoring our code gives us a performance boost, here we trade memory space for time. So, we must only memoize a component when necessary.

 

Memoization is an optimization method that involves storing the results of a component-rendered operation in memory, and then returning the cached result for the same input. This means that if a child component is given a prop, a memoized component will compare the prop shallowly by default and will skip re-rendering the child component if the prop has not changed. This is illustrated as follows:

 

import { useState } from “react”;

 

export default function App() {

  const [input, setInput] = useState(“”);

  const [count, setCount] = useState(0);

 

  return (

    <div>

      <input

        type=”text”

        value={input}

        onChange={(e) => setInput(e.target.value)}

      />

      <button onClick={() => setCount(count + 1)}>Increment counter</button>

      <h3>Input text: {input}</h3>

      <h3>Count: {count}</h3>

      <hr />

      <ChildComponent count={count} />

    </div>

  );

}

 

function ChildComponent({ count }) {

  console.log(“child component is rendering”);

  return (

    <div>

      <h2>This is a child component.</h2>

      <h4>Count: {count}</h4>

    </div>

  );

}

 

By updating the input field, the App component and ChildComponent re-render, which you can see here. Instead, the ChildComponent should only re-render when clicking the count button, because it must update the UI.”Here, we can memoize the ChildComponent to optimize our app’s performance.

 

Implementing React.memo():

React.memo is a type of higher-order component that can be used to wrap a purely functional component, to avoid re-rendering of the component as long as the received props remain unchanged.

 

import React, { useState } from “react”;

 

// …

 

const ChildComponent = React.memo(function ChildComponent({ count }) {

  console.log(“child component is rendering”);

  return (

    <div>

      <h2>This is a child component.</h2>

      <h4>Count: {count}</h4>

    </div>

  );

});

 

If the count prop never changes, React will skip rendering the ChildComponent and reuse the previously rendered result, ultimately improving React’s performance.

 

When we use React.memo() to pass props, it is efficient to use primitive values such as numbers. Primitive values always return true if their values never change, which is known as referential equality.

 

 However, non-primitive values such as objects, which include arrays and functions, always return false because they point to different memory spaces. As a result, when we pass non-primitive values as props, the memoized component will always re-render.

 

import React, { useState } from “react”;

 

export default function App() {

  // …

 

  const incrementCount = () => setCount(count + 1);

 

  return (

    <div>

      {/* … */}

      <ChildComponent count={count} onClick={incrementCount} />

    </div>

  );

}

 

const ChildComponent = React.memo(function ChildComponent({ count, onClick }) {

  console.log(“child component is rendering”);

  return (

    <div>

      {/* … */}

      <button onClick={onClick}>Increment</button>

      {/* … */}

    </div>

  );

});

 

The main focus of this code is the incrementCount function passing to the ChildComponent. When the App component re-renders, even when the count button is not clicked, the function redefines, making the ChildComponent also re-render.

 

To avoid this unnecessary redefinition of the function, we can utilize the useCallback Hook. This will allow us to return a memoized version of the callback that can be reused between renders.

 

Using the useCallback() and useMemo() Hooks

 

With the useCallback Hook, the incrementCount function only redefines when the count dependency array changes:

 

const incrementCount = React.useCallback(() => setCount(count + 1), [count]);

 

In order to optimize performance in a child component that receives an array or object as a prop, we can utilize the useMemo Hook to store the value between renders. This is because such values are completely new and exist in different memory spaces, as we have already discussed.

 

Another way to utilize the useMemo Hook is to prevent the repeated computation of an expensive value within a component. This allows us to memoize these values and only recalculate them if the dependencies change. Similar to the useCallback Hook, the useMemo Hook requires a function and an array of dependencies.

 

const memoizedValue = React.useMemo(() => {

  // return expensive computation

}, []);

Let’s see how to apply the useMemo Hook to improve a React app’s performance. Take a look at the following code that we’ve intentionally delayed to be very slow:

 

import React, { useState } from “react”;

 

const expensiveFunction = (count) => {

  // artificial delay (expensive computation)

  for (let i = 0; i < 1000000000; i++) {}

  return count * 3;

};

 

export default function App() {

  // …

  const myCount = expensiveFunction(count);

  return (

    <div>

      {/* … */}

      <h3>Count x 3: {myCount}</h3>

      <hr />

      <ChildComponent count={count} onClick={incrementCount} />

    </div>

  );

}

 

const ChildComponent = React.memo(function ChildComponent({ count, onClick }) {

  // …

});

 

When we run this code, we experience a lag in our application whenever we try to enter text in the input field and when the count button is clicked. This is because every time the App component renders, it invokes the expensiveFunction and slows down the app.

 

To optimize the app’s performance, we need to ensure that the expensiveFunction is only called when the count button is clicked, not when typing in the input field. To achieve this, we can make use of the useMemo Hook to memoize the returned value of the expensiveFunction. This will allow the function to recompute only when necessary, such as when the count button is clicked. To implement this solution, we can use the following code:

 

const myCount = React.useMemo(() => {

  return expensiveFunction(count);

}, [count]);

 

Now, if we test the app once again, we will no longer experience a lag while typing in the input field:

 

Code-splitting in React using dynamic import()

 

Code-splitting is a technique that can optimize a React application. By default, the entire code for a React application is loaded at once when it renders in a browser, in the form of a bundle file that contains all the necessary code files. While bundling helps reduce the number of HTTP requests required for a page, as an application grows, so does the bundle file size. This can slow down the initial page load and reduce user satisfaction.

 

With code-splitting, however, React allows us to break up a large bundle file into smaller chunks using dynamic import() and then lazy load these chunks on-demand with React.lazy. This approach significantly improves the performance of complex React applications.

 

To use code-splitting, we need to modify a standard React import statement as follows:

 

import Home from “./components/Home”;

import About from “./components/About”;

 

Then, into something like this:

 

const Home = React.lazy(() => import(“./components/Home”));

const About = React.lazy(() => import(“./components/About”));

By using this syntax in React, the components are loaded dynamically. This means that when a user navigates to a specific page, such as the homepage, React will only download the file for that page instead of a large bundle file for the entire application. Once the components are imported, they need to be rendered within a Suspense component as shown below:

 

<React.Suspense fallback={<p>Loading page…</p>}>

  <Route path=”/” exact>

    <Home />

  </Route>

  <Route path=”/about”>

    <About />

  </Route>

</React.Suspense>

 

The Suspense allows us to display a loading text or indicator as a fallback while React waits to render the lazy component in the UI.

 

Windowing or list virtualization in React applications

 

Suppose there is an application that displays multiple rows of items on a page. Even if some of the items are not visible in the browser viewport, they are still rendered in the DOM, which can impact the application’s performance.

 

To optimize the rendering performance of a large list, the concept of windowing can be utilized. This involves rendering only the visible portion of the list to the user, and then dynamically replacing the items that exit the viewport as the user scrolls through the list. Two widely used libraries for implementing this technique are react-window and react-virtualized.

 

Using immutable data structures

 

The concept of immutable data structures can be explained simply – rather than modifying a complex data object directly, a copy of the object is created with the updates. This allows for easy comparison between the original object and the new one by comparing their references, which can trigger a UI update. It’s important to treat React state as immutable and avoid directly altering it. To better understand this process, let’s take a look at an example:

 

export default function App() {

  const [bookInfo, setBookInfo] = useState({

    name: “A Cool Book”,

    noOfPages: 28

  });

 

  const updateBookInfo = () => {

   bookInfo.name = ‘A New title’

  };

  return (

    <div className=”App”>

      <h2>Update the book’s info</h2>

      <pre>

        {JSON.stringify(bookInfo)}

      </pre>

      <button onClick={updateBookInfo}>Update</button>

    </div>

  );

}

 

In the updateBookInfo function, we try to update the bookInfo state directly. However, this approach can lead to performance issues since React cannot monitor such changes and update the user interface accordingly. To address this, we can handle the bookInfo state as an immutable data structure instead of trying to modify it directly.

 

const updateBookInfo = () => {

    const newBookInfo = { …bookInfo };

    newBookInfo.name = “A Better Title”;

    setBookInfo(newBookInfo);

  };

 

To ensure React can accurately track and reflect any changes to the state, we adopt a method in updateBookInfo that involves creating a duplicate of the bookInfo object, modifying it, and then passing the modified version to setBookInfo, rather than updating the state directly. We have the option of implementing immutability ourselves, but we can also utilize third-party libraries like Immer and Immutable.js for this purpose.

Using Reselect in Redux to Optimize Rendering

 

React and Redux  often work well together,but when the state changes, Redux may cause performance issues due to excessive re-rendering. To avoid such issues, one can utilize reselect, a selector library for Redux.

 

Reselect includes a createSelector function that generates memoized selectors. A memoized selector caches its value and only recalculates or re-renders when the value changes. You can refer to the Redux documentation for additional information on selectors and their functionality.

 

Conclusion

 

In order to enhance the user experience, it is crucial to identify and resolve any performance issues within our React application. This guide provides instructions on measuring the performance of the application and optimizing it for optimal results.

 

admin

admin

Leave a Reply

Your email address will not be published. Required fields are marked *