Boost React List Performance: Unleashing Efficiency with React.memo, useCallback, and Proven Optimization Techniques

Boost React List Performance: Unleashing Efficiency with React.memo, useCallback, and Proven Optimization Techniques

Featured on Hashnode

Let's embark on an interactive journey to solve a problem using React and optimize the performance of our list. Hold on tight and get ready for some coding fun!

To begin, we have a task at hand: implement a feature that allows item selection. But wait, there are requirements we need to consider:

  1. Clicking an item should select or unselect it.

  2. Multiple items can be selected simultaneously.

  3. We must ensure that unnecessary re-renders of each list item are avoided to maintain optimal performance.

  4. Visual highlighting should be applied to the currently selected items.

  5. The names of the currently selected items should be displayed at the top of the page.

Are you ready for the challenge? Excellent! Let's dive in and conquer this task together.

First, we have a component called App. This component receives an array of items as a prop. Inside the App component, we render a list of items using the map function. Each item is represented as an <li> element with a unique key based on the item's name. Additionally, we apply a CSS class to give each item a color based on its provided color value.

Congratulations, adventurer! We've set the stage for our interactive selection feature. Now it's time to roll up our sleeves, get creative, and code our way to victory. Let's ensure a delightful user experience with smooth performance and visually appealing selections. Together, we shall conquer this challenge!

Let's start by modifying the code to make the items selectable and display the names of the selected items at the top. We'll use React hooks, specifically useState, to manage the selected items.

import { useState } from 'react';
import './styles.css';

const App = ({ items }) => {
  const [itemsSelected, setItemSelected] = useState({});

  const handleClick = (name) => {
    const updatedItems = { ...itemsSelected };
    updatedItems[name] = !updatedItems[name];
    setItemSelected(updatedItems);
  };

  return (
    <>
      <span>
        {Object.keys(itemsSelected)
          .filter((name) => itemsSelected[name])
          .join(',')}
      </span>
      <ul className="List">
        {items.map((item) => (
          <li
            key={item.name}
            className={`List__item List__item--${item.color} ${
              itemsSelected[item.name] ? 'selected' : ''
            }`}
            onClick={() => handleClick(item.name)}
          >
            {item.name}
          </li>
        ))}
      </ul>
    </>
  );
};

export default App;

In this updated code, we modified the function component App to use the useState hook to manage the itemsSelected state. The handleClick function is responsible for updating the selection status of the items.

The selected item names are displayed within a span element. We use Object.keys(itemsSelected).filter((name) => itemsSelected[name]) to filter the selected item names and join them with commas.

The ul element renders the list of items. Each li element has a conditional CSS class selected based on the selection status of the item. Clicking on an item triggers the handleClick function, which updates the selection status in the itemsSelected state.

Using an object instead of an array for itemsSelected offers better time complexity for checking item selection. Here's a concise summary of the benefits:

  1. Constant Time Complexity: Object property access has a time complexity of O(1), while array search is O(n). This results in faster lookups with a large number of items.

  2. Efficient Updates: Modifying an object by key is faster than searching and updating an array, which requires iteration.

  3. No Duplicate Entries: Objects ensure unique items by their keys, avoiding duplicate entries in the selection list.

  4. Flexibility in Key-Value Data: Objects allow storing additional item-specific information as values associated with their keys.

  5. Easier Item Removal: Removing an item from an object by key is efficient, unlike an array that requires searching and shifting elements.

In conclusion, using an object enhances time complexity for item selection checks, facilitates efficient updates, prevents duplicates, enables additional item-specific data, and simplifies item removal. These advantages make object-based storage ideal for large lists or frequent item selection operations.

Addressing Performance Challenges: Profiling for Optimization

Having successfully completed the basic task, it's time to tackle the performance hurdles. To optimize our application effectively, we can leverage profiling techniques. Profilers provide invaluable insights into the performance of our React application, enabling us to identify and address any slowdowns or bottlenecks.

When working with React, we have two primary profiler options at our disposal: the built-in profiler within the browser's Dev Tools (such as Chrome) and the React DevTools extension profiler. Each profiler offers unique advantages in different scenarios. In my experience, starting with the React DevTools profiler is a recommended approach. It provides a component-centric performance overview, allowing us to pinpoint specific components that might be causing performance issues. On the other hand, the browser's profiler operates at a lower level and is more helpful when dealing with non-component-related slowdowns, like slow methods or Redux reducers.

For our optimization journey, let's begin by utilizing the React DevTools profiler. Ensure that you have the extension installed, and then access the Profiler tool through your browser's Dev Tools. Before we dive in, let's configure a couple of settings to enhance our optimization process:

  • In the Performance tab of your browser's Dev Tools, adjust the CPU throttling to a slower setting (e.g., x6). This simulation will make any performance slowdowns more pronounced and easier to detect.

  • In the React DevTools Profiler tab, locate the Gear icon and click on it.

  • Within the Profiler settings, enable the option "Record why each component rendered while profiling". This setting allows us to track down the reasons behind unnecessary re-renders more accurately.

With the configuration complete, we can now proceed to profile our code. Follow these steps:

  1. Click the Record button in the React DevTools Profiler.

  2. Interact with the app by selecting various items in the list.

  3. Once you have finished interacting, click the Stop Recording button.

By profiling our application, we can gather valuable performance data. This data will help us identify any areas where re-rendering is occurring unnecessarily. Let's examine an example of the results obtained after selecting three items:

Our biggest problem is that selecting a single item causes all the items to be re-rendered, and that’s what we are going to tackle in the next section


Considering the expected re-rendering behavior after an action, an important question arises: How many items should we anticipate to re-render? In this specific scenario, the answer is one. When a click event occurs, a new item is selected, while the remaining items remain unaffected.


Optimizing Performance: Preventing Re-renders with React.memo

In the previous section, we encountered a crucial issue where selecting a single item triggered the unnecessary re-rendering of the entire list. To overcome this challenge, we can utilize the power of React.memo, a higher-order component.

In essence, React.memo performs a comparison between the new and previous props. If the props remain the same, the previous render is reused. However, if the props differ, the component undergoes a re-render. It's important to note that React.memo conducts a shallow comparison of the props. This aspect should be considered when passing objects and methods as props. Although it's possible to override the comparison function, it's generally advised against as it can compromise code maintainability (more on this later).

Now that we have a basic understanding of React.memo, let's create a new component by wrapping the ListItem component with it:

import { memo } from "react";

const MemoizedListItem = memo(ListItem);

With the MemoizedListItem available, we can replace ListItem with it in our list:

{items.map((item) => (
  <MemoizedListItem
    key={item.name}
    item={item}
    onClick={() => handleClick(item.name)}
    isSelected={itemsSelected[item.name]}
  >
    {item.name}
  </MemoizedListItem>
))}

Great! We have successfully memoized the ListItem component. However, If we revisit the profiler and record a selection, we would see that all the items are still being re-rendered.

Hovering over one of the list items reveals the "Why did this render?" section, indicating that the props have changed due to the onClick callback being passed to each item.

By default, React.memo performs a shallow comparison of props. In our case, this means using the strict equality operator (===) on each prop. Comparing name and selected works as expected since they are primitive types (string and boolean, respectively). However, onClick, being a function, is compared by reference. In our implementation, we passed an anonymous closure as the onClick callback:

onClick={() => handleClick(item.name)}

With each re-render, a new callback function is created, resulting in a change in equality. Consequently, the MemoizedListItem component is re-rendered unnecessarily.


To grasp the concept of equality, you can open the JavaScript console in your browser. If you compare true === true, you will obtain true as the result. However, comparing (() => {}) === (() => {}) will yield false. This behavior stems from the fact that functions are considered equal only when they share the same identity. Creating a new closure generates a new identity each time.


To prevent these unnecessary re-renders, we need a way to maintain the identity of the onClick callback. In the upcoming sections, we will explore and address this issue in detail.

Ensuring Stable Callback Identities

In React, the traditional method to preserve function identities is by utilizing the useCallback hook. By passing a function and a dependency array, useCallback ensures that the identity of the callback remains stable as long as the dependencies remain unchanged. Let's apply this technique to our example:

First, we'll extract the anonymous closure () => handleClick(item.name) a separate function within useCallback:

function App({ items }) {
  const [itemsSelected, setItemSelected] = useState({});

  const handleClick = useCallback(() => {
    // How do we get the name?
    const updatedItems = { ...itemsSelected };
    updatedItems[name] = !updatedItems[name];
    setItemSelected(updatedItems);
  }, [itemsSelected]);

  return (
    <>
      <span>
        {Object.keys(itemsSelected)
          .filter((name) => itemsSelected[name])
          .join(",")}
      </span>
      <ul className="List">
        {items.map((item) => (
          <ListItem
            key={item.name}
            item={item}
            onClick={handleClick}
            isSelected={itemsSelected[item.name]}
          >
            {item.name}
          </ListItem>
        ))}
      </ul>
    </>
  );
}

However, we encounter a new challenge. Previously, the anonymous closure captured the current item name within the .map iteration and passed it as an argument to the handleClick function. Now, since we have moved the handleClick handler outside the iteration, we need to find a way to access the "selected item name" within the callback. Let's explore a potential solution:

Improving ListItem Component and onClick Identity

In order to provide the necessary information about the selected item, we need to refactor the ListItem component and introduce changes to the App component as well.
Let's start by refactoring the ListItem component. Currently, the onClick callback does not provide any information about the selected item. To address this, we modify the ListItem component to have a new onClick handler that includes the item name as an argument. Here is the updated ListItem component:

const ListItem = ({ item, isSelected, onClick }) => {
  return (
    <li
      key={item.name}
      className={`List__item List__item--${item.color} ${
        isSelected ? "selected" : ""
      }`}
      onClick={() => onClick(item.name)}
    >
      {item.name}
    </li>
  );
};

However, you might be wondering if using an anonymous closure in the li's onClick handler could lead to re-renderings. While we discussed the issues with anonymous closures earlier, in this case, there is no performance benefit from having a stable identity for the onClick callback because we are not memoizing the li element. Thus, creating another memoized callback with useCallback inside the ListItem component would not improve performance.


Next, we update the App component to utilize the updated item name information in the handleClick callback:

function App({ items }) {
  const [itemsSelected, setItemSelected] = useState({});

  const handleClick = useCallback((name) => {
    const updatedItems = { ...itemsSelected };
    updatedItems[name] = !updatedItems[name];
    setItemSelected(updatedItems);
  }, [itemsSelected]);

  return (
    <>
      <span>
        {Object.keys(itemsSelected)
          .filter((name) => itemsSelected[name])
          .join(",")}
      </span>
      <ul className="List">
        {items.map((item) => (
          <ListItem
            key={item.name}
            item={item}
            onClick={handleClick}
            isSelected={itemsSelected[item.name]}
          >
            {item.name}
          </ListItem>
        ))}
      </ul>
    </>
  );
}

With the refactored App component, we make use of the updated item name information in the handleClick callback.

While these changes address providing the necessary item information and utilizing it in the onClick callback, there is still a performance issue. If we examine the profiler results, we can see that the entire list is still being rendered:

This suggests that the onClick identity is still changing, resulting in the handleClick identity being altered with each re-render. In the next section, we will explore additional optimizations to address this performance issue and achieve better rendering efficiency.

Addressing the handleClick Identity Problem

In the previous section, we discussed the issue with the handleClick callback, where its dependency on itemsSelected caused it to re-render with each change in itemsSelected identity. To solve this problem, we need to find a way to make handleClick a pure function and ensure its identity remains stable. Fortunately, we can achieve this using the functional updates provided by useState.

  const handleClick = useCallback((name) => {
    setItemSelected((prevItemsSelected) => {
      const updatedItems = { ...prevItemsSelected };
      updatedItems[name] = !updatedItems[name];
      return updatedItems;
    });
  }, []);

In the updated code, we wrap our previous logic inside the setItemSelected call. This allows us to access the previous state value (prevItemsSelected) and compute the new selected items accordingly.

When running the refactored example, we observe that it functions correctly and performs optimally. To gain further insight, we can use the profiler to examine the rendering behavior:

  • Hovering on the item being rendered:

  • Hovering on the other items:

As demonstrated, after selecting an item, only the currently selected item is rendered, while the others are efficiently memoized. This improvement greatly enhances the rendering efficiency of our application.

Exploring List Virtualization

In this article, we've focused on optimizing rendering performance by utilizing memoization techniques and preventing unnecessary re-renders. Before concluding, I'd like to touch on the concept of list virtualization, which is a popular technique employed to enhance performance for long lists. It involves rendering only a subset of items that are currently visible and deferring the rendering of others until they are needed, such as when the user scrolls.

List virtualization offers notable benefits over rendering the entire list:

  1. Faster initial start time: By rendering only a subset of the list, the initial rendering process becomes quicker, as fewer items need to be processed and displayed.

  2. Lower memory usage: Since only a subset of items is rendered at any given time, the memory consumption is reduced, resulting in better memory utilization.

However, it's important to note that list virtualization is not a universal solution that should always be applied. It introduces complexity and can occasionally introduce glitches. In scenarios where the list contains only a few hundred items, the memoization techniques discussed in this article are often sufficient to achieve optimal performance. However, on older mobile devices or when dealing with significantly larger lists, virtualization can offer substantial performance improvements.

It's crucial to consider the specific use case before deciding whether to implement list virtualization. Profiling the list and analyzing its performance can provide valuable insights into whether virtualization is necessary.

Conclusion

Throughout this article, we have delved into the intricacies of optimizing list performance in React. We began with a problematic example and gradually addressed various performance issues, providing effective solutions along the way. Additionally, we explored common anti-patterns and discussed strategies to overcome them.

In summary, lists often become a significant source of performance bottlenecks in React applications, primarily due to default behavior that triggers the re-rendering of all items whenever a change occurs. Fortunately, we discovered that React.memo serves as a valuable tool for mitigating this issue. However, achieving optimal results may require refactoring your application to ensure stable identities for the props.

If you're keen to explore the final implementation, you can access the complete code in this CodeSandbox.

By optimizing your React lists, you can significantly enhance the overall performance and user experience of your applications. Remember to profile your application to identify any lingering performance concerns and apply the appropriate optimization techniques as needed.

Thank you for joining us on this journey to optimize React lists for improved performance. With the knowledge gained from this article, you can confidently tackle performance challenges in your React projects and build blazing-fast user interfaces.

Did you find this article valuable?

Support Yaswanth Gosula by becoming a sponsor. Any amount is appreciated!