React performance optimization - memoization demystified

Abhay V Ashokan

By Abhay V Ashokan

on June 13, 2023

When it comes to building fast React applications, performance is a top priority. Luckily, React has clever techniques built-in that take care of performance optimizations automatically. In fact, React does most of the heavy lifting for you, so you can focus on building your app without worrying too much about performance tweaks. However, as your React application scales and becomes more complex, there are opportunities to further enhance its speed and efficiency.

In this blog, we will focus on how the memoization of components and proper code splitting helps you squeeze the most out of your application. We assume that you have a high-level understanding of how useCallback, useMemo, and React.memo works. If so, let's directly jump right in.

Initial setup

We'll go through the process of building a website that helps teams manage and discuss customer feedback. The application contains a dashboard where different categories of feedback are organized. The user can easily navigate between each category and view all the feedback classified under it. It's important to note that for the purpose of this blog, we will be focusing on building a dashboard prototype rather than a fully functional application, incorporating a significant amount of demo data.

Customer feedback dashboard

The dashboard consists of mainly four components. The Header component displays the category of feedback and also lets you easily navigate to other categories. The Category component displays all the feedback related to the selected category. On the right side, the Info section provides personalized details about the user who is currently logged in. All these parts are encapsulated in the App component, creating a cohesive dashboard.

App.jsx

The App component renders the Header and Category components corresponding to the selected category. For the sake of demonstration, we store all the dummy data in the FEEDBACK_CATEGORIES variable. By default, the first category is selected. We shall pass a DEFAULT_USER constant to render the Info component.

1import React, { useState } from "react";
2
3import Category from "./Category";
4import { DEFAULT_USER, FEEDBACK_CATEGORIES } from "./constants";
5import Header from "./Header";
6import Info from "./Info";
7
8const App = () => {
9  const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0);
10  const totalCategories = FEEDBACK_CATEGORIES.length;
11  const category = FEEDBACK_CATEGORIES[selectedCategoryIndex];
12
13  const gotoNextCategory = () => {
14    setSelectedCategoryIndex(index => (index + 1) % totalCategories);
15  };
16
17  const gotoPrevCategory = () => {
18    setSelectedCategoryIndex(
19      index => (index + totalCategories - 1) % totalCategories
20    );
21  };
22
23  return (
24    <div className="flex justify-between">
25      <div className="w-full">
26        <Header
27          gotoNextCategory={gotoNextCategory}
28          gotoPrevCategory={gotoPrevCategory}
29          title={category.title}
30        />
31        <Category category={category} />
32      </div>
33      <Info user={DEFAULT_USER} />
34    </div>
35  );
36};
37
38export default App;

Header.jsx

The Header component renders the feedback title and implements pagination for seamless navigation between feedbacks.

1import React from "react";
2
3const Header = ({ title, gotoNextCategory, gotoPrevCategory }) => (
4  <div className="flex justify-between p-4 shadow-sm">
5    <h1 className="text-xl font-bold">{title}</h1>
6    <div className="space-x-2">
7      <button
8        className="rounded bg-blue-500 py-1 px-2 text-white"
9        onClick={gotoPrevCategory}
10      >
11        Previous
12      </button>
13      <button
14        className="rounded bg-blue-500 py-1 px-2 text-white"
15        onClick={gotoNextCategory}
16      >
17        Next
18      </button>
19    </div>
20  </div>
21);
22
23export default Header;

Category.jsx

The Category component displays a list of feedbacks based on selected category, facilitating a bird's-eye view of all the feedbacks.

1import React from "react";
2
3const Category = ({ category }) => (
4  <div className="mx-auto my-4 w-full max-w-xl space-y-4">
5    {category.feedbacks.map(({ id, user, description }) => (
6      <div key={id} className="rounded shadow px-6 py-4 w-full">
7        <p className="font-semibold">{user}</p>
8        <p className="text-gray-600">{description}</p>
9      </div>
10    ))}
11  </div>
12);
13
14export default Category;

Info.jsx

The Info component displays the information of the currently logged in user.

1import React from "react";
2
3const Info = ({ user }) => (
4  <div className="flex h-screen flex-col bg-gray-100 p-8">
5    <p>{user.name}</p>
6    <p className="font-semibold text-blue-500">{user.email}</p>
7    <button className="text-end mt-auto block text-sm font-semibold text-red-500">
8      Log out
9    </button>
10  </div>
11);
12
13export default Info;

Here is a CodeSandbox link for you to jump right in and try out all the changes yourselves.

Profiling and optimizing with React profiler

Now let's have a look at what the React Profiler has to say when we click on the "Next" and "Previous" buttons to navigate between the different feedbacks.

React profiler for initial code

Clearly, every component is re-rendered whenever the user navigates to another feedback. This is not ideal. We can argue that the Info component need not be re-rendered since the user information stays constant across every render. Let us wrap the default export of the Info component with React.memo and see how it improves the performance.

1export default React.memo(Info);

React profiler for memoized Info component

It's now clear that subsequent renders use the cached version of the Info component boosting the overall performance.

Let's now explore how we can improve the performance of the Header component further. There is no point in memoizing the Header component itself since the title is updated whenever we navigate to a new category. We can see that the pagination buttons need not re-render whenever we navigate to a new category. Hence, it is possible to extract these buttons into their own component and memoize it to prevent these unnecessary re-renders.

Header.jsx

1import React from "react";
2
3import Pagination from "./Pagination";
4
5const Header = ({ title, gotoNextCategory, gotoPrevCategory }) => (
6  <div className="flex justify-between p-4 shadow-sm">
7    <h1 className="text-xl font-bold">{title}</h1>
8    <div className="space-x-2">
9      <Pagination
10        gotoNextCategory={gotoNextCategory}
11        gotoPrevCategory={gotoPrevCategory}
12      />
13    </div>
14  </div>
15);
16
17export default Header;

Pagination.jsx

1import React from "react";
2
3const Pagination = ({ gotoNextCategory, gotoPrevCategory }) => (
4  <>
5    <button
6      className="rounded bg-blue-500 py-1 px-2 text-white"
7      onClick={gotoPrevCategory}
8    >
9      Previous
10    </button>
11    <button
12      className="rounded bg-blue-500 py-1 px-2 text-white"
13      onClick={gotoNextCategory}
14    >
15      Next
16    </button>
17  </>
18);
19
20export default React.memo(Pagination);

This did not solve the problem. In fact, React.memo did not prevent any unnecessary re-renders. By definition, React.memo lets you skip re-rendering a component when its props are unchanged. After close inspection, you will understand that the references to the gotoNextCategory and gotoPrevCategory functions get updated whenever the App component re-renders. This causes the Pagination component to re-render as well. Here we should use the useCallback hook to cache the function references before passing them as props. This would maintain the referential equality of the functions across renders and let React.memo does its magic.

App.jsx

1// Rest of the code
2
3const App = () => {
4  // Rest of the code
5
6  const gotoNextCategory = useCallback(() => {
7    setSelectedCategoryIndex(index => (index + 1) % totalCategories);
8  }, []);
9
10  const gotoPrevCategory = useCallback(() => {
11    setSelectedCategoryIndex(
12      index => (index + totalCategories - 1) % totalCategories
13    );
14  }, []);
15
16  // Rest of the code
17};
18
19export default App;

Now, you may use the profiler to verify that the Pagination component is not re-rendered unnecessarily.

React profiler with memoized pagination

Let us now introduce a new feature. The users should be able to filter the feedback in a particular category based on a search term. We shall modify the Category component to incorporate it.

Feedback search feature

Category.jsx

1import React, { useState } from "react";
2
3const Category = ({ category }) => {
4  const [searchTerm, setSearchTerm] = useState("");
5
6  const filteredFeedbacks = category.feedbacks.filter(({ description }) =>
7    description.toLowerCase().includes(searchTerm.toLowerCase().trim())
8  );
9
10  return (
11    <div className="mx-auto my-4 w-full max-w-xl space-y-4">
12      <input
13        autoFocus
14        className="outline-gray-200 w-full border p-2"
15        placeholder="Search feedbacks"
16        value={searchTerm}
17        onChange={e => setSearchTerm(e.target.value)}
18      />
19      {filteredFeedbacks.map(({ id, user, description }) => (
20        <div key={id} className="rounded shadow px-6 py-4 w-full">
21          <p className="font-semibold">{user}</p>
22          <p className="text-gray-600">{description}</p>
23        </div>
24      ))}
25    </div>
26  );
27};
28
29export default Category;

From the above code, it is very clear that the individual feedback need not be re-rendered every time the search term is updated. Hence, it would be a good idea to extract it to a Card component and wrap it with React.memo.

Category.jsx

1import React, { useState } from "react";
2
3import Card from "./Card";
4
5const Category = ({ category }) => {
6  const [searchTerm, setSearchTerm] = useState("");
7
8  const filteredFeedbacks = category.feedbacks.filter(({ description }) =>
9    description.toLowerCase().includes(searchTerm.toLowerCase().trim())
10  );
11
12  return (
13    <div className="mx-auto my-4 w-full max-w-xl space-y-4">
14      <input
15        autoFocus
16        className="outline-gray-200 w-full border p-2"
17        placeholder="Search feedbacks"
18        value={searchTerm}
19        onChange={e => setSearchTerm(e.target.value)}
20      />
21      {filteredFeedbacks.map(({ id, user, description }) => (
22        <Card key={id} user={user} description={description} />
23      ))}
24    </div>
25  );
26};
27
28export default Category;

Card.jsx

1import React from "react";
2
3const Card = ({ user, description }) => (
4  <div className="w-full rounded px-6 py-4 shadow">
5    <p className="font-semibold">{user}</p>
6    <p className="text-gray-600">{description}</p>
7  </div>
8);
9
10export default React.memo(Card);

If the number of feedbacks is too large, the calculation of filteredFeedbacks will become expensive. We need to traverse through all the comments one by one and then perform a custom search logic on each comment object. We can use the useMemo hook to cache the results preventing the same computation across renders boosts the performance further.

Let us verify the React profiler one last time. Clearly, only the Category component re-renders with the new changes, taking the rest of the results from the cache.

React profiler useMemo and memoized Card

To enhance the overall user experience, it's important to reset the search term whenever users navigate to a new category. This can be easily achieved by passing a key prop to the Category component. React will maintain separate component trees for each category, ensuring that the search functionality starts anew in each category.

1// Rest of the code
2
3const App = () => {
4  // Rest of the code
5
6  return (
7    <div className="flex justify-between">
8      <div className="w-full">
9        <Header
10          title={feedback.title}
11          gotoNextCategory={gotoNextCategory}
12          gotoPrevCategory={gotoPrevCategory}
13        />
14        <Category key={category.id} category={category} />
15      </div>
16      <Info user={DEFAULT_USER} />
17    </div>
18  );
19};
20
21export default App;

Wrapping up

By breaking down components and utilizing memoization, we have improved the performance of our app significantly. The React Profiler has been instrumental in identifying areas for optimization and validating the effectiveness of our enhancements. With these techniques, you can now build faster and more responsive React applications. Apply these learnings to your projects and elevate your React development skills.

Here is a CodeSandbox link of the optimized website for you to play with.

Stay up to date with our blogs. Sign up for our newsletter.

We write about Ruby on Rails, ReactJS, React Native, remote work,open source, engineering & design.