React is one of the most popular front-end frameworks, and in addition to being funded by Facebook, it’s built around key concepts (one-way data flow, immutable data, function components, hooks) that make it easier than ever to create powerful applications. That said, it’s not without pitfalls.

It’s easy to write inefficient code in React, and useless re-renders are common. Typically, you start with a simple application and gradually build functionality on top of it. At first, the application is small enough that the inefficiencies are not apparent, but as complexity increases, so does the component hierarchy, and therefore the number of re-renders. Then, as soon as the application speed becomes unbearable (by your standards), you start analyzing and optimizing the problem areas.

In this article, we’ll discuss tuning lists, which are notorious sources of performance issues in React. Most of these technologies work with React and React Native applications.

Start with a problematic example

The example is a simple list of optional items, but there are some performance issues. Clicking on an item toggles the selection status, but the operation is significantly delayed. Our goal is to make choices feel fast.

import { useState } from "react";

// Create simulated data
const data = new Array(100)
  .fill()
  .map((_, i) = > i + 1)
  .map((n) = > ({
    id: n,
    name: `Item ${n}`
  }));

export default function App() {
  // Contains the selected array
  const [selected, setSelected] = useState([]);

  const toggleItem = (item) = > {
    if(! selected.includes(item)) { setSelected([...selected, item]); }else {
      setSelected(selected.filter((current) = > current !== item));
    }
  };

  return (
    <div className="App">
      <h1>List Example</h1>
      <List data={data} selectedItems={selected} toggleItem={toggleItem} />
    </div>
  );
}

const List = ({ data, selectedItems, toggleItem }) = > {
  return (
    <ul>
      {data.map((item) => (
        <ListItem
          name={item.name}
          selected={selectedItems.includes(item)}
          onClick={()= > toggleItem(item)}
        />
      ))}
    </ul>
  );
};

const ListItem = ({ name, selected, onClick }) = > {
  // Run expensive operations to simulate load, and in real-world JS applications there can be other relevant business code
  expensiveOperation(selected);

  return (
    <li
      style={selected ? { textDecoration: "line-through"}: undefined}
      onClick={onClick}
    >
      {name}
    </li>
  );
};

// This is an example of an expensive JS operation that we might perform in a rendering function to simulate load.
const expensiveOperation = (selected) = > {
  // We use selected here only because we want to simulate an operation that depends on props
  let total = selected ? 1 : 0;
  for (let i = 0; i < 200000; i++) {
    total += Math.random();
  }
  return total;
};
Copy the code

Missing key props

The first thing we can notice from the console is that key is not passing prop when rendering list items.

This is caused by the following code 👇 :

{data.map((item) = > (
  <ListItem
    name={item.name}
    selected={selectedItems.includes(item)}
    onClick={()= > toggleItem(item)}
  />
))}
Copy the code

As you probably already know, keys are crucial for dynamic lists in React because they help the framework identify which items have changed, been added, or removed.

Ordinary beginners usually solve the problem by passing each item’s index:

{data.map((item, index) = > (
  <ListItem
    key={index}
    name={item.name}
    selected={selectedItems.includes(item)}
    onClick={()= > toggleItem(item)}
  />
))}
Copy the code

Although suitable for simple use cases, this approach can lead to a variety of unexpected behaviors when the list is dynamically adding or removing items. For example, if you delete items in the middle of the list at index N, all list items at position N+1 will now have different keys. This causes React to “confuse” which mapping component belongs to which project. This article is a great resource if you want to learn more about the potential pitfalls of using indexes as keys.

Therefore, you should specify a unique key for each entry. If you receive data from the back end, you might use the unique ID of the database as the key. Otherwise, you can use nanoid to generate random client ids when creating projects.

Fortunately, each of our own projects has its own ID attribute, so we should handle it this way:

{data.map((item) = > (
  <ListItem
    key={item.id}
    name={item.name}
    selected={selectedItems.includes(item)}
    onClick={()= > toggleItem(item)}
  />
))}
Copy the code

Once the key is added and the warning is resolved, the analysis can begin.

Analysis of the list

Now that we have solved the key warning, we are ready to solve the performance problem. At this stage, using profilers helps track down slow areas to guide our optimizations, and that’s what we’re going to do.

With React, you can use two main profilers: built-in profilers in the browser, such as those available in Chrome’s developer tools, and profilers provided by the React DevTools extension. They are useful in different scenarios. Based on my experience, the React DevTools analyzer is a good starting point, because it provides developers with a component perceived performance, said that helps to track leads to problems specific components, and browser parser working at lower levels, and it have no direct relation with the component in performance problem under the condition of very useful, for example, Due to method slow or Redux reducer.

For this reason, we’ll start with the React DevTools profiler, so make sure you have the extension installed. We can then access the Profiler tool from Chrome development Tools > Profiler. Before we begin, we’ll set two Settings to help optimize the process:

  • In Chrome’s Performance TAB, set the CPU throttling to X6. This simulates a slower CPU, making deceleration more pronounced.

  • On the React DevTools Profiler TAB, click the gear icon > Profiler > Record Why each component Rendered while profiling. This will help us track down the cause of useless re-rendering.

With the configuration complete, we are ready to analyze our sample Todo application. Go ahead and click the Record button, then select some items in the list, and finally click Stop Record. This is what we got when we selected three items:

In the upper right corner, you’ll see commits highlighted in red, which in short, are renderings that cause DOM updates. As you can see, the current commit took 2671 ms to render. By hovering over various elements, we can see that most of the time is spent rendering list items, which take an average of 26 milliseconds per item.

Taking 26 milliseconds to render a single project is not in itself a bad thing. As long as the entire operation takes less than 100 milliseconds, users will still consider the operation agile. Our biggest problem was that selecting a single item would cause all items to be re-rendered, and that’s what we’ll need to address next.

One question we should ask ourselves at this point is: What is the expected number of items to be rerendered after an action? In this particular case, the answer is one, because the result of a click is that a new project is selected, and none of the other projects are affected. Another scenario might be a radio list, where at most one item can be selected at any given time. In this case, clicking on an item causes both items to be re-rendered, because we need to render both the selected and unselected items.

Use React. Memo to prevent re-rendering

Above we discussed how selecting a single item causes the entire list to be rerendered, and ideally we just want to re-render clicking on the one affected by the new selection. We can do this using the React. Memo high-level component.

In short, react. memo compares the new props to the old props, and if they are equal, it reuses the previous render. Otherwise, it rerenders the component if the props are different. Note that React performs a shallow comparison of props, which must be taken into account when passing objects and methods as props.

You can also override the comparison function, but I recommend against doing so as it makes the code more difficult to maintain (more on that later).

Now that we know about React. Memo, let’s create another component, ListItem:

import { memo } from "react";

const MemoizedListItem = memo(ListItem);
Copy the code

We can now use MemoizedListItem in the list instead of: ListItem

 {data.map((item) = > (
    <MemoizedListItem
      key={item.id}
      name={item.name}
      selected={selectedItems.includes(item)}
      onClick={()= > toggleItem(item)}
    />
  ))}
Copy the code

Ok! We’ve now processed listitems using Memo. If you continue to try toggle selection, you’ll see that there’s a problem – it’s still slow!

If we open the parser as before and record a selection, we should see something like this:

As you can see, all items are still being re-rendered! Why is that?

If you hover over one of the list items, you’ll see “Why did this Reander?” Part. In our case, it means Props Changed: (onClick), which means that our project is being re-rendered due to the callbacks we passed to each project. onClick

As we discussed earlier, by default, React. Memo makes a shallow comparison to props. This basically means calling the strict equality operator === on each props, which in our case is roughly equivalent to:

function arePropsEqual(prevProps, nextProps) {
  return prevProps.name === nextProps.name &&
         prevProps.selected === nextProps.selected &&
         prevProps.onClick === nextProps.onClick
}
Copy the code

While Name and selected are compared by value (because they are primitive types, a string and a Boolean, respectively), by reference onClick (as a function). When we create a list item, we pass the callback as an anonymous closure:

onClick

onClick={() = > toggleItem(item)}
Copy the code

Every time the list is re-rendered, each item receives a new callback function from a strictly equal point of view, the callback has changed, so MemoizedListItem is re-rendered. If you are still confused about the equality aspect, go ahead and open the JS console in your browser. If you type true === true, you will notice that the result is true. But if you type (() => {}) === (() => {}), you get false. That’s because two functions are equal only if they share the same identity, and every time we create a new closure, we generate a new identity.

Therefore, we need a way to keep the identity of the callback onClick stable to prevent useless re-rendering, which is what we need to discuss next.

Common processing methods

Before discussing suggested solutions, let’s examine the common handling used in these cases. Given that the React. Memo method accepts a custom function comparator, you might not want to compare onClick to something like the following:

const MemoizedListItem = memo(
  ListItem,
  (prevProps, nextProps) = >
    prevProps.name === nextProps.name &&
    prevProps.selected === nextProps.selected
    // Do not compare onClick props
);
Copy the code

In this case, even with the changed onClick callback, the list item will not be rerendered unless name or Selected is updated. If you keep trying this approach, you’ll notice that lists now feel neat, but something is wrong, and that there are problems when clicking back and forth to select several items, and items are randomly selected and cancelled.

This happens because the toggleItem function is not pure because it depends on the previous value of the Selected item. If updates to onClick are not checked for in the react.Memo callback check function, the component may receive a callback from an outdated (stale) version, causing these failures.

In this particular case, the way toggleItem is implemented is not optimal, and we could easily turn it into a pure function, but my point is that by excluding the compare onClick callback from the Memo comparator, we are exposing the application to subtle stale errors.

Some might argue that this approach is perfectly acceptable as long as the onClick callback remains pure. Personally, I think this is neither good nor bad for two reasons:

  • In a complex code base, it is relatively easy to mistakenly convert pure functions into impure ones.
  • By writing a custom comparator, you add an additional maintenance burden. If in the futureListItemWhat if you need to accept another parameter? Let’s say we add onecolorProperties, and then you need to refactormemoThe comparator is shown below. If you forget to add it (which is relatively easy to do in complex code with multiple people working together), the problem will recede.
const MemoizedListItem = memo(
  ListItem,
  (prevProps, nextProps) = >
    prevProps.name === nextProps.name &&
    prevProps.selected === nextProps.selected &&
    prevProps.color === nextProps.color  // Add color attribute, but forget to add comparison.
);
Copy the code

If the use of custom comparators is not recommended, how can we solve this problem?

makeonClickThe callback function is stable

Our goal is to use a “base” version of The Memo without a custom comparator. Choosing this path improves both the maintainability of the component and its robustness to future changes. However, in order for Memoization to work properly, we need to refactor the onClick callback to keep it stable, otherwise the equality check performed on React.Memo will prevent Memoization.

The traditional way to keep functions’ identities stable in React is to use the useCallback hook. A hook takes a function and an array of dependencies, and as long as the dependencies don’t change, neither does the identity of the callback. Let’s refactor our example to use useCallback:

Our first attempt is to move the anonymous closure to a separate method inside useCallback () => toggleItem(item) :

const List = ({ data, selectedItems, toggleItem }) = > {
  const handleClick = useCallback(() = > {
    toggleItem(??????) // How do we get item?
  }, [toggleItem])

  return (
    <ul>
      {data.map((item) => (
        <MemoizedListItem
          key={item.id}
          name={item.name}
          selected={selectedItems.includes(item)}
          onClick={handleClick}
        />
      ))}
    </ul>
  );
};
Copy the code

We now have a problem: in the previous anonymous closure form, the item was retrieved in the.map iteration and then passed as an argument to the toggleItem function when it was executed. But how do we pass item as an argument to handleClick in this case?

Let’s discuss a possible solution:

Refactoring the ListItem component

Currently, ListItem’s onClick callback does not provide any information about the selected item. If so, we can easily solve this problem, so let’s refactor the ListItem and List components to provide this information.

First, we changed the ListItem component to accept the full Item object, and since NameProp is now redundant, we removed it. We then introduce a handler for the onClick event that provides item as an argument. This is our final result: \

const ListItem = ({ item, selected, onClick }) = > {
  // Run expensive operations to simulate load, and in real-world JS applications there can be other relevant business code
  expensiveOperation(selected);

  return (
    <li
      style={selected ? { textDecoration: "line-through"}: undefined}
      onClick={()= > onClick(item)}
    >
      {item.name}
    </li>
  );
};
Copy the code

As you can see, onClick now provides the current item as an argument.

One might ask at this point that the onClick on the Li tag in the code uses anonymous closures, shouldn’t we avoid doing this to prevent rerendering? While we could use useCallback to create another Memoized callback within the component to handle ListItem’s click event, it would not provide any performance improvement in this case. The problem with anonymous closures we discussed earlier is List which breaks MemoizedListItem in React.memo. Since we don’t have the Memo li element, whether the onClick callback on Li is up to date or not isn’t going to provide much of a performance advantage.

We can then refactor the List component to pass ItemProp, rather than name using the newly available Item information in the callback: handleClick

const List = ({ data, selectedItems, toggleItem }) = > {
  const handleClick = useCallback(
    (item) = > {  // We now receive the selected item
      toggleItem(item);
    },
    [toggleItem]
  );

  return (
    <ul>
      {data.map((item) => (
        <MemoizedListItem
          key={item.id}
          item={item}  // We pass the full item instead of the name
          selected={selectedItems.includes(item)}
          onClick={handleClick}
        />
      ))}
    </ul>
  );
};
Copy the code

Ok! Let’s go ahead and try the refactored version:

It works, but it’s still slow! If we open the parser, we can see that the entire list is still being rendered:

As you can see from the profiler, the onClick identity is still changing! This means that our handleClick identity changes every time we re-render.

Another common problem

Before delving into the correct solution, let’s discuss common problems used in these situations. Since useCallback accepts dependent arrays, you might want to specify an empty array to leave handleClick unchanged:

  const handleClick = useCallback((item) = >{ toggleItem(item); } []);Copy the code

Although it remains the same,But this approach suffers from the same outdated errors we discussed in the previous sections.

If we run it, you’ll notice that these items are deselected when we specify a custom comparator:

In general, you should always specify the correct dependencies in useCallback, otherwise you will expose your application to potentially stale errors that are difficult to debug. useEffect,useMemo

Solve toggleItem identification problem

As we discussed earlier, the problem with our handleClick callback is that its toggleItem dependency token changes every time it renders, causing it to rerender as well:

  const handleClick = useCallback((item) = > {
    toggleItem(item);
  }, [toggleItem]);
Copy the code

Our first attempt was to use useCallback handling like handleClick:

  const toggleItem = useCallback(
    (item) = > {
      if(! selected.includes(item)) { setSelected([...selected, item]); }else {
        setSelected(selected.filter((current) = >current ! == item)); } }, [selected] );Copy the code

This doesn’t solve the problem, however, because the callback relies on the external state variable selected, which changes with each setSelected call. If we want it to stay the same, we need a way to make the toggleItem pure. Fortunately, we can use useState’s feature update to achieve our goal:

  const toggleItem = useCallback((item) = > {
    setSelected((prevSelected) = > {
      if(! prevSelected.includes(item)) {return [...prevSelected, item];
      } else {
        return prevSelected.filter((current) = >current ! == item); }}); } []);Copy the code

As you can see, we wrap the previous logic in the setSelected call, which in turn provides the previous state value we need to evaluate the newly selected item.

If we continue to run the refactored example, it works and is agile! We can also run the usual profiler to see what’s going on:

Hover over the item being rendered:

Hover over other items:

As you can see, after selecting an item, we render only the items currently being selected, while the other items are in memo.

Description of function status updates

In the example we just discussed, it is relatively simple to convert our toggleItem method to the function pattern useState. In a real-world scenario, things may not be that simple.

For example, your function may depend on multiple state values:

 const [selected, setSelected] = useState([]);
 const [isEnabled, setEnabled] = useState(false);

  const toggleItem = useCallback((item) = > {
    // What should we do if project switching is only allowed when enabled?
    if (isEnabled) {
      setSelected((prevSelected) = > {
        if(! prevSelected.includes(item)) {return [...prevSelected, item];
        } else {
          return prevSelected.filter((current) = > current !== item);
        }
      });
    }
  }, [isEnabled]);
Copy the code

Every time the isEnabled value changes, your toggleItem status changes. In these cases, you should either merge the two substates into the same useState call, or use useReducer to better convert them to one state. Given that useReducer’s dispatch function has stable identities, you can extend this method to complex states. In addition, this also applies to Redux’s dispatch function, so you can move the item-switching logic at the Redux level and convert our toggleItem function to:

  const dispatch = useDispatch();

  // Since dispatch is stable, 'toggleItem' will also be stable
  const toggleItem = useCallback((item) = > {
    dispatch(toggleItemAction(item))
  }, [dispatch]);
Copy the code

Virtual list?

Before I close, I want to briefly mention list virtualization, a common technique used to improve the performance of long lists. In short, list virtualization is based on the idea of rendering only a subset of the items in a given list (usually the currently visible items) and deferring the others. For example, if you have a list of a thousand items, but only 10 are visible at any given time, then we might render only those 10 first, while the others can be rendered as needed (that is, after scrolling).

List virtualization offers two major advantages over rendering an entire list:

  • Faster initial startup time because we only need to render a subset of the list
  • Low memory utilization because only a subset of projects are rendered at any given time

That said, list virtualization is not a panacea that we need to use all the time, because it adds complexity and can fail. Personally, I’d avoid using virtual lists if you’re only dealing with a few hundred items, because the Memo technique we discuss in this article is usually valid enough (older mobile devices may require a lower threshold). As always, the right approach depends on the specific use case, so I strongly recommend that you analyze your list before delving into more complex optimization techniques.

conclusion

In this article, we take a closer look at list optimization. We started with a problematic example and worked our way through most of the performance issues. We also discussed some of the issues to be aware of in development and potential ways to address them.

In summary, lists are often the cause of performance problems in React, because by default all items are rerendered with each change. React. Memo is a useful tool for mitigating problems, but you may need to refactor the application so that the props remain the same.

You can find the final code in CodeSandbox if you’re interested.

PS: useMemo also has a small optimization to add in our example, can you find out for yourself?

❤️ Three things to watch:

If you find this article inspiring, I’d like to ask you to do me a small favor:

  1. Like, so that more people can see this content, but also convenient to find their own content at any time (collection is not like, are playing rogue -_-);
  2. Pay attention to us, not regular points good article;
  3. Look at other articles as well;

🎉 You are welcome to write your own learning experience in the comments section, and discuss with me and other students. Feel free to share this article with your friends if you find it rewarding.