15 min read

Efficient rendering of really large lists with react-select

02.02.2021

Introduction

Using React as the UI framework of a web application always leaves developers with many choices through the development process. One of the most popular decisions to make is which Select component to use. React-select (react-select.com) is the first choice when a select component (with many other features) is required - it has 20.5k stars on GitHub and 2 million weekly downloads on npm. 

Like any common open-source component, react-select solves the common use cases very well. However, at the edges, react-select falls short sometimes and calls for customizations.

The problem we want to tackle with react-select is quite common: we want to allow selection from a large list of options. How large? We've seen the component start to break at around 1,000 options. 

Keep reading and we'll show you how it breaks, and also demonstrate to you how we improved the performance by using another open-source library - react-window. We'll also dive into the react-window implementation to understand how the improvement is achieved.

By the way - the entire code used in the post is public.

The problem - React select with large lists

To demonstrate the problem, we've created a new project with create-react-app. We added the react-select component by running `yarn add react-select`.

The most simple code for displaying a select component would be:


import React, { useState } from "react";
import Select from "react-select";
import "./App.css";
 
const options = [...Array(100).keys()].map(idx => ({
 value: `value-${idx}`,
 label: `label-${idx}`,
}));
 
function App() {
 const [inputValue, setInputValue] = useState("");
 
 return (
   <div className="App">
     <header>
       <h1>React-select With Large Lists</h1>
     </header>
     <div style={{ width: "30%", margin: "auto" }}>
       <Select
         inputValue={inputValue}
         onInputChange={val => setInputValue(val)}
         options={options}
       />
     </div>
   </div>
 );
}
 
export default App;



This would result in a high-performance select component, with 100 options. We'll use the same code to generate a large list of items to include in the select.

The following two gifs compare the performance of a select with 100 options to that of 10,000 options:

React select 100 options React select 10,000 options

React select 100 options

React select 10,000 options

 

The performance degradation is significant. With 10,000 options, the react-select component takes about 5 seconds to open its options menu. Why should it take so long? In both cases, only the first 8 or so options are visible. This is the first hint to how react-window approaches this problem - let's see more details.

React-window to the rescue

The key to improving the performance is limiting the rendering of the list. It's enough to only render 8 items while keeping the same usability as rendering all 10,000 items. 

React-window is another prevalent open-source library, with 8.5k stars on Github and 300,000 npm downloads. React-window is used to render large lists in a virtual way: only the visible elements of the list are actually rendered.

How do we combine the two? Lucky for us, react-select exposes an API that allows us to replace any of its core building blocks. In this case, we would like to replace the MenuList component with our own, custom, MenuList. The new declaration of react-select would look like so:

 ...
<Select
         inputValue={inputValue}
         onInputChange={val => setInputValue(val)}
         options={options}
         components={{
           MenuList: CustomMenuList,
         }}
/>
...

And now in our own CustomMenuList component, we utilize react-window to virtually render the list of options. To enable the virtualization, we must provide the virtual list with some basic data about the list: the list's height, and the height of each item in the list. That way the component knows exactly how many and which items to render. 

The list's height is passed to the component as the props of the MenuList component, as provided by the react-select library. We determine the item heights ourselves, per our usability needs.

This is how the CustomMenuList ends up looking:

import { FixedSizeList as List } from "react-window";
 
const CustomMenuList = props => {
 const itemHeight = 35;
 const { options, children, maxHeight, getValue } = props;
 const [value] = getValue();
 const initialOffset = options.indexOf(value) * itemHeight;
 
 return (
   <div>
     <List
       height={maxHeight}
       itemCount={children.length}
       itemSize={itemHeight}
       initialScrollOffset={initialOffset}
     >
       {({ index, style }) => <div style={style}>{children[index]}</div>}
     </List>
   </div>
 );
};

The first thing we would like to see is performance improvement. This is the normal react-select rendering alongside the virtualized list one:

 

React select 100 options

React select 10,000 options

 

The time to render with the virtualized list is almost the same as the list with 100 options. Also - the rest of the react-select functionality is enhanced: filtering options by value search, selecting an option or scrolling down the list. These were almost unusable before virtualizing the list.

React-window Shallow Dive

React-window solves the performance problem by virtualizing the list, and only rendering the visible items. At this point, it's important to note that mounting elements to the real (i.e non-virtual) DOM is the most expensive operation for the browser to perform. In order to avoid performance degradation, we will try to minimize the number of such operations.  What does it look like in the real DOM? 

This is the DOM of the normal react-select options list:

And this is the new DOM with the virtual list:


The new DOM only has 9 list items. Of course, when scrolling down the list the items that are rendered change, but the amount of items remains 9 all the same.

To better understand how this is achieved, we can inspect the react-window code. The key is a couple of methods called getStartIndexForOffset and getStopIndexForStartIndex:

getStartIndexForOffset: (
   { itemCount, itemSize }: Props<any>,
   offset: number
 ): number =>
   Math.max(
     0,
     Math.min(itemCount - 1, Math.floor(offset / ((itemSize: any): number)))
   ),
 
getStopIndexForStartIndex: (
   { direction, height, itemCount, itemSize, layout, width }: Props<any>,
   startIndex: number,
   scrollOffset: number
 ): number => {
   const isHorizontal = direction === 'horizontal' || layout === 'horizontal';
   const offset = startIndex * ((itemSize: any): number);
   const size = (((isHorizontal ? width : height): any): number);
   const numVisibleItems = Math.ceil(
     (size + scrollOffset - offset) / ((itemSize: any): number)
   );
   return Math.max(
     0,
     Math.min(
       itemCount - 1,
       startIndex + numVisibleItems - 1 // -1 is because stop index is inclusive
     )
   );
 },

The start index is calculated by the scroll offset, and according to the item's height. The stop index is fitted to the start index and is calculated by the provided height and item height. The visible items are inferred by the start and stop indexes, and rendered to the screen accordingly.

 

Conclusion

React-select is a great choice for select components in React applications. We saw that the component might break for a large list use case, and also how to handle it. React-select is smart to provide a strong customization API, which should be a consideration when choosing an open-source component.

Moreover, we explained how to use react-window, as a very straightforward solution to handling large amounts of data, and rendering long lists efficiently.

The virtualization of list rendering can be used in many cases. Any time we would want to render a long list, we can utilize virtual rendering to enhance performance.