DEV Community

Cover image for 5 React performance optimization techniques
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

5 React performance optimization techniques

Written by Ibadehin Mojeed ✏️

Optimizing application performance is key for developers who are mindful of keeping a user’s experience positive to keep them on an app and engaged.

According to research by Akamai, a second delay in load time can cause a 7% reduction in conversions, making it imperative for developers to create apps with optimized performance.

For applications built with React, we are guaranteed a very fast UI by default. However, as an application grows, developers may encounter some performance issues.

In this guide, we will discuss five important ways to optimize the performance of a React application, including pre-optimization techniques. These include:

React pre-optimization techniques

Before optimizing a React application, we must understand how React updates its UI and how to measure an app’s performance. This makes it easy to solve any React performance problems.

Let’s start by reviewing how the React UI updates.

Understanding how React updates its UI

When we create a rendered component, React creates a virtual DOM for its element tree in the component. Now, whenever the state of the component changes, React recreates the virtual DOM tree and compares the result with the previous render.

It then only updates the changed element in the actual DOM. This process is called diffing.

React uses the concept of a virtual DOM to minimize the performance cost of rerendering a webpage because the actual DOM is expensive to manipulate.

This is great because it speeds up the UI render time. However, this concept can also slow down a complex app if it’s not managed very well.

What we can deduce here is that a state change in a React component causes a rerender. Likewise, when the state passes down to a child component as a prop, it rerenders in the child and so on, which is fine because React must update the UI.

The issue comes when the child components are not affected by the state change. In other words, they do not receive any prop from the parent component.

React nonetheless rerenders these child components. So, as long as the parent component rerenders, all of its child components rerender regardless of whether a prop passes to them or not; this is the default behavior of React.

Let’s quickly demonstrate this concept. Here, we have an App component holding a state and a child component:

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>;
};
Enter fullscreen mode Exit fullscreen mode

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

Open the console in this CodeSandbox demo and write something in the input field. We’ll see that for every keystroke, the ChildComponent rerenders.

In most cases, this rerendering shouldn’t cause performance issues, and we shouldn’t notice any lag in our application. However, if the unaffected component renders an expensive computation and we notice performance issues, then we should optimize!

This brings us to the second pre-optimization technique.

Profiling the React app to understand where bottlenecks are

React allows us to measure the performance of our apps using the Profiler in the React DevTools. There, we can gather performance information every time our application renders.

The profiler records how long it takes a component to render, why a component is rendering, and more. From there, we can investigate the affected component and provide the necessary optimization.

To use the Profiler, we must install the React DevTools for our browser of choice. If you don’t have it installed yet, head over to their extension page and install it (choose for Chrome here or for Firefox here).

Now, we should see the Profiler tab when working on a React project.

Back to our code, if we profile the application, we see the following behavior:

The DevTools profiler highlights every rendered component while the input text field updates and we receive every detail from the rendered components. In the flame chart below, we can see how long it took to render the components and why the App component is rendering.

Receiving Details From Rendered Components, Shows Why App Renders

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

Shows Child Component Rendering Because Parent Component Rendering

This can impact the app’s performance if we have an operation in a child component that takes time to compute. This brings us to our optimization techniques.

React performance optimization techniques

1. Keeping component state local where necessary

We’ve learned that a state update in a parent component rerenders the parent and its child components.

So, to ensure rerendering a component only happens when necessary, we can extract the part of code that cares about the component state, making it local to that part of the code.

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>;
}
Enter fullscreen mode Exit fullscreen mode

This ensures that only the component that cares about the state renders. In our code, only the input field cares about the state. So, we extracted that state and the input to a FormInput component, making it a sibling to the ChildComponent.

This means, when the state changes in the FormInput component, only the component rerenders.

If we test the app once again in our CodeSandbox demo, the ChildComponent no longer rerenders on every keystroke.

But sometimes, we cannot avoid having a state in a global component while passing it down to child components as a prop. In this case, let’s learn how to avoid rerendering the unaffected child components.

2. Memoizing React components to prevent unnecessary rerenders

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 strategy that caches a component-rendered operation, saves the result in memory, and returns the cached result for the same input.

In essence, if a child component receives a prop, a memoized component shallowly compares the prop by default and skips rerendering the child component if the prop hasn’t changed:

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

By updating the input field, the count button rerenders the [App](https://codesandbox.io/s/elegant-fast-6nmig?file=/src/App.js) and ChildComponent.

Instead, the ChildComponent should only rerender when clicking the count button because it must update the UI. In this case, we can memoize the ChildComponent.

Using React.memo()

By wrapping a purely functional component in React.memo, we want to rerender the component only if its prop changes:

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>
  );
});
Enter fullscreen mode Exit fullscreen mode

If the count prop never changes, React will skip rendering the ChildComponent and reuse the previous rendered result. Hence improving the app performance.

You can try this in the tutorial on CodeSandbox.

React.memo() works pretty well when we pass down primitive values, such as a number in our example. And, if you are familiar with referential equality, primitive values are always referentially equal and return true if values never change.

Nonprimitive values like object, which include arrays and functions, always return false between rerenders. This is because when the component rerenders, the object is being redefined.

When we pass down object, array, or function as a prop, the memoized component always rerenders. Here, we are passing down a function to the child component:

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>
  );
});
Enter fullscreen mode Exit fullscreen mode

This code focuses on the incrementCount function passing to the ChildComponent. When the App component rerenders, even when the count button is not clicked, the function redefines, making the ChildComponent also rerender.

To prevent the function from always redefining, we will use a useCallback Hook that returns a memoized version of the callback between renders.

Using the useCallback Hook

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

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

You can try it for yourself on CodeSandbox.

Using the useMemo Hook

When the prop we pass down to a child component is an array or object, we can use a useMemo Hook to memoize the value between renders. This allows us to avoid recomputing the same value in a component.

Similar to useCallback, the useMemo Hook also expects a function and an array of dependencies:

const memoizedValue = React.useMemo(() => {
  // return expensive computation
}, []);
Enter fullscreen mode Exit fullscreen mode

3. Code-splitting in React using dynamic import()

Code-splitting is another important optimization technique for a React application.

By default, when a React application renders in a browser, a “bundle” file containing the entire application code loads and serves to users at once. This file generates by merging all the code files needed to make a web application work.

The idea of bundling is useful because it reduces the number of HTTP requests a page can handle. However, as an application grows, the file sizes increase, thus increasing the bundle file.

At a certain point, this continuous file increase slows the initial page load, reducing the user’s satisfaction.

With code-splitting, React allows us to split a large bundle file into multiple chunks using dynamic import() followed by lazy loading these chunks on-demand using the React.lazy. This strategy greatly improves the page performance of a complex React application.

To implement code-splitting, we transform a normal React import like this:

import Home from "./components/Home";
import About from "./components/About";
Enter fullscreen mode Exit fullscreen mode

And then into something like this:

const Home = React.lazy(() => import("./components/Home"));
const About = React.lazy(() => import("./components/About"));
Enter fullscreen mode Exit fullscreen mode

This syntax tells React to load each component dynamically. So, when a user follows a link to the home page, for instance, React only downloads the file for the requested page instead of loading a large bundle file for the entire application.

After the import, we must render the lazy components inside a Suspense component like so:

<React.Suspense fallback={<p>Loading page...</p>}>
  <Route path="/" exact>
    <Home />
  </Route>
  <Route path="/about">
    <About />
  </Route>
</React.Suspense>
Enter fullscreen mode Exit fullscreen mode

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.

You can try this out yourself in the CodeSandbox tutorial.

4. Windowing or list virtualization in React

Imagine we have an application where we render several rows of items on a page. Whether or not any of the items display in the browser viewport, they render in the DOM and may affect the performance of our application.

With the concept of windowing, we can render to the DOM only the visible portion to the user. Then, when scrolling, the remaining list items render while replacing the items that exit the viewport. This technique can greatly improve the rendering performance of a large list.

Both react-window and react-virtualized are two popular windowing libraries that can implement this concept.

5. Lazy loading images in React

To optimize an application that consists of several images, we can avoid rendering all of the images at once to improve the page load time. With lazy loading, we can wait until each of the images is about to appear in the viewport before we render them in the DOM.

Similar to the concept of windowing mentioned above, lazy loading images prevents the creation of unnecessary DOM nodes, boosting the performance of our React application.

react-lazyload and react-lazy-load-image-component are popular lazy loading libraries that can be used in React projects.

Conclusion

To start an optimization process, we must first find a performance problem in our application to rectify. In this guide, we’ve explained how to measure the performance of a React application and how to optimize the performance for a better user experience.

If you like this guide, ensure you share it around the web. Also, let me know which of the techniques interest you the most.


Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard 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 Dashboard Free Trial Banner

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 — start monitoring for free.

Discussion (2)

Collapse
guruprasaths profile image
guruprasath-s

Nice article.
Small request if you could add examples for useMemo hook with objects or arrays it really helps.
Appreciate it.

Collapse
mlnzyx profile image
R. Maulana Citra

Nice article there, thanks for sharing!