DEV Community

Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Rendering large lists with React Virtualized

http://www.reactvirtualized.com

A common requirement in web applications is displaying lists of data. Or tables with headers and scrolls. You have probably done it hundreds of times.

But what if you need to show thousands of rows at the same time?

And what if techniques like pagination or infinite scrolling are not an option (or maybe there are but you still have to show a lot of information)?

In this article, I’ll show you how to use react-virtualized to display a large amount of data efficiently.

First, you’ll see the problems with rendering a huge data set.

Then, you’ll learn how React Virtualized solves those problems and how to efficiently render the list of the first example using the List and Autosizer components.

You’ll also learn about two other helpful components. CellMeasurer, to dynamically measure the width and height of the rows, and ScrollSync, to synchronize scrolling between two or more virtualized components.

You can find the complete source code of the examples used here in this GitHub repository.

The problem

Let’s start by creating a React app:

npx create-react-app virtualization

This app is going to show a list of one thousand comments. Something like this:

The placeholder text will be generated with the library lorem-ipsum, so cd into your app directory and install it:

cd virtualization
npm install --save lorem-ipsum

Now in src/App.js, import lorem-ipsum:

import loremIpsum from 'lorem-ipsum';

And let’s create an array of one thousand elements in the following way:

const rowCount = 1000;
class App extends Component {
  constructor() {
    super();
    this.list = Array(rowCount).fill().map((val, idx) => {
      return {
        id: idx, 
        name: 'John Doe',
        image: 'http://via.placeholder.com/40',
        text: loremIpsum({
          count: 1, 
          units: 'sentences',
          sentenceLowerBound: 4,
          sentenceUpperBound: 8 
        })
      }
    });
  }
  //...

The above code will generate an array of one thousand objects with the properties:

  • id
  • name
  • image
  • And a sentence of between four and eight words

This way, the render() method can use the array like this:

render() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <h1 className="App-title">Welcome to React</h1>
      </header>
      <div className="list">
        {this.list.map(this.renderRow)}
      </div>
    </div>
  );
}

Using the method renderRow() to create the layout of each row:

renderRow(item) {
  return (
    <div key={item.id} className="row">
      <div className="image">
        <img src={item.image} alt="" />
      </div>
      <div className="content">
        <div>{item.name}</div>
        <div>{item.text}</div>
      </div>
    </div>
  );
}

Now, if you add some CSS styles to src/App.css:

.list {
  padding: 10px;
}
.row { 
  border-bottom: 1px solid #ebeced;
  text-align: left;
  margin: 5px 0;
  display: flex;
  align-items: center;
}
.image {
  margin-right: 10px;
}
.content {
  padding: 10px;
}

And run the app with npm start, you should see something like this:

You can inspect the page using the Elements panel of your browser’s developer tools.

It shouldn’t be a surprise to find one thousand div nodes in the DOM:

So many elements in the DOM can cause two problems:

  • Slow initial rendering
  • Laggy scrolling

However, if you scroll through the list, you may not notice any lagging. I didn’t. After all, the app isn’t rendering something complex.

But if you’re using Chrome, follow these steps to do a quick test:

  1. Open the Developer tools panel.
  2. Press Command+Shift+P (Mac) or Control+Shift+P (Windows, Linux) to open the Command Menu.
  3. Start typing Rendering in the Command Menu and select Show Rendering.
  4. In the Rendering tab, enable FPS Meter.
  5. Scroll through the list one more time.

In my case, the frames went from 60 to around 38 frames per second:

That’s not good.

In less powerful devices or with more complex layouts, this could freeze the UI or even crash the browser.

So how can we display these one thousand rows in an efficient way?

One way is by using a library like react-virtualized, which uses a technique called virtual rendering.

How does react-virtualized work?

The main concept behind virtual rendering is rendering only what is visible.

There are one thousand comments in the app, but it only shows around ten at any moment (the ones that fit on the screen), until you scroll to show more.

So it makes sense to load only the elements that are visible and unload them when they are not by replacing them with new ones.

React-virtualized implements virtual rendering with a set of components that basically work in the following way:

  • They calculate which items are visible inside the area where the list is displayed (the viewport).
  • They use a container (div) with relative positioning to absolute position the children elements inside of it by controlling its top, left, width and height style properties.

There are five main components:

  • Grid. It renders tabular data along the vertical and horizontal axes.
  • List. It renders a list of elements using a Grid component internally.
  • Table. It renders a table with a fixed header and vertically scrollable body content. It also uses a Grid component internally.
  • Masonry. It renders dynamically-sized, user-positioned cells with vertical scrolling support.
  • Collection. It renders arbitrarily positioned and overlapping data.

These components extend from React.PureComponent, which means that when comparing objects, it only compares their references, to increase performance. You can read more about this here.

On the other hand, react-virtualized also includes some HOC components:

  • ArrowKeyStepper. It decorates another component so it can respond to arrow-key events.
  • AutoSizer. It automatically adjusts the width and height of another component.
  • CellMeasurer. It automatically measures a cell’s contents by temporarily rendering it in a way that is not visible to the user.
  • ColumnSizer. It calculates column-widths for Grid cells.
  • InfiniteLoader. It manages the fetching of data as a user scrolls a List, Table, or Grid.
  • MultiGrid. It decorates a Grid component to add fixed columns and/or rows.
  • ScrollSync.It synchronizes scrolling between two or more components.
  • WindowScroller. It enables a Table or List component to be scrolled based on the window’s scroll positions.

Now let’s see how to use the List component to virtualize the one thousand comments example.

Virtualizing a list

First, in src/App.js, import the List component from react-virtualizer:

import { List } from "react-virtualized";

Now instead of rendering the list in this way:

<div className="list">
{this.list.map(this.renderRow)}
</div>

Let’s use the List component to render the list in a virtualized way:

const listHeight = 600;
const rowHeight = 50;
const rowWidth = 800;
//...
<div className="list">
<List
width={rowWidth}
height={listHeight}
rowHeight={rowHeight}
rowRenderer={this.renderRow}
rowCount={this.list.length} />
</div>

Notice two things.

First, the List component requires you to specify the width and height of the list. It also needs the height of the rows so it can calculate which rows are going to be visible.

The rowHeight property takes either a fixed row height or a function that returns the height of a row given its index.

Second, the component needs the number of rows (the list length) and a function to render each row. It doesn’t take the list directly.

For this reason, the implementation of the renderRow method needs to change.

This method won’t receive an object of the list as an argument anymore. Instead, the List component will pass it an object with the following properties:

  • index.The index of the row.
  • isScrolling. Indicates if the List is currently being scrolled.
  • isVisible. Indicates if the row is visible on the list.
  • key. A unique key for the row.
  • parent. A reference to the parent List component.
  • style. The style object to be applied to the row to position it.

Now the renderRow method will look like this:

renderRow({ index, key, style }) {
  return (
    <div key={key} style={style} className="row">
      <div className="image">
        <img src={this.list[index].image} alt="" />
      </div>
      <div className="content">
        <div>{this.list[index].name}</div>
        <div>{this.list[index].text}</div>
      </div>
    </div>
  );
}

Note how the index property is used to access the element of the list that corresponds to the row that is being rendered.

If you run the app, you’ll see something like this:

In my case, eight and a half rows are visible.

If we look at the elements of the page in the developer tools tab, you’ll see that now the rows are placed inside two additional div elements:

The outer div element (the one with the CSS class ReactVirtualized__GridReactVirtualized__List) has the width and height specified in the component (800px and 600px, respectively), has a relative position and the value auto for overflow (to add scrollbars).

The inner div element (the one with the CSS class ReactVirtualized__Grid__innerScrollContainer) has a max-width of 800px but a height of 50000px, the result of multiplying the number of rows (1000) by the height of each row (50). It also has a relative position but a hidden value for overflow.

All the rows are children of this div element, and this time, there are not one thousand elements.

However, there are not eight or nine elements either. There’s like ten more.

That’s because the List component renders additional elements to reduce the chance of flickering due to fast scrolling.

The number of additional elements is controlled with the property overscanRowCount. For example, if I set 3 as the value of this property:

<List
width={rowWidth}
height={listHeight}
rowHeight={rowHeight}
rowRenderer={this.renderRow}
rowCount={this.list.length}
overscanRowCount={3} />

The number of elements I’ll see in the Elements tab will be around twelve.

Anyway, if you repeat the frame rate test, this time you’ll see a constant rate of 59/60 fps:

Also, take a look at how the elements and their top style is updated dynamically:

The downside is that you have to specify the width and height of the list as well as the height of the row.

Luckily, you can use the AutoSizer and CellMeasurer components to solve this.

Let’s start with AutoSizer.

Autoresizing a virtualized list

Components like AutoSizer use a pattern named function as child components.

As the name implies, instead of passing a component as a child:

<AutoSizer>
<List
...
/>
</AutoSizer>

You have to pass a function. In this case, one that receives the calculated width and height:

<AutoSizer>
({ width, height }) => {
}
</AutoSizer>

This way, the function will return the List component configured with the width and height:

<AutoSizer>
({ width, height }) => {
return <List
width={width}
height={height}
rowHeight={rowHeight}
rowRenderer={this.renderRow}
rowCount={this.list.length}
overscanRowCount={3} />
}
</AutoSizer>

The AutoSizer component will fill all of the available space of its parent so if you want to fill all the space after the header, in src/App.css, you can add the following line to the list class:

.list {
...
height: calc(100vh - 210px)
}

The vh unit corresponds to the height to the viewport (the browser window size), so 100vh is equivalent to 100% of the height of the viewport. 210px are subtracted because of the size of the header (200px) and the padding that the list class adds (10px).

Import the component if you haven’t already:

import { List, AutoSizer } from "react-virtualized";

And when you run the app, you should see something like this:

If you resize the window, the list height should adjust automatically:

Calculating the height of a row automatically

The app generates a short sentence that fits in one line, but if you change the settings of the lorem-ipsum generator to something like this:

this.list = Array(rowCount).fill().map((val, idx) => {
return {
//...
text: loremIpsum({
count: 2,
units: 'sentences',
sentenceLowerBound: 10,
sentenceUpperBound: 100
})
}
});

Everything becomes a mess:

That’s because the height of each cell has a fixed value of 50. If you want to have dynamic height, you have to use the CellMeasurer component.

This component works in conjunction with CellMeasurerCache, which stores the measurements to avoid recalculate them all the time.

To use these components, first import them:

import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized";

Next, in the constructor, create an instance of CellMeasurerCache:

class App extends Component {
  constructor() {
    ...
    this.cache = new CellMeasurerCache({
      fixedWidth: true,
      defaultHeight: 100
    });
  }
  ...
}

Since the width of the rows doesn’t need to be calculated, the fixedWidth property is set to true.

Unlike AutoSizer, CellMeasurer doesn’t take a function as a child, but the component you want to measure, so modify the method renderRow to use it in this way:

renderRow({ index, key, style, parent }) {
    return (
      <CellMeasurer 
        key={key}
        cache={this.cache}
        parent={parent}
        columnIndex={0}
        rowIndex={index}>
          <div style={style} className="row">
            <div className="image">
              <img src={this.list[index].image} alt="" />
            </div>
            <div className="content">
              <div>{this.list[index].name}</div>
              <div>{this.list[index].text}</div>
            </div>
          </div>
      </CellMeasurer>
    );
  }

Notice the following about CellMeasuer:

  • This component is the one that is going to take the key to differentiate the elements.
  • It takes the cache configured before.
  • It takes the parent component (List) where it’s going to be rendered, so you also need this parameter.

Finally, you only need to modify the List component so it uses the cache and gets its height from that cache:

<AutoSizer>
{
  ({ width, height }) => {
    return <List
      width={width}
      height={height}
      deferredMeasurementCache={this.cache}
      rowHeight={this.cache.rowHeight}
      rowRenderer={this.renderRow}
      rowCount={this.list.length}
      overscanRowCount={3} />
  }
}
</AutoSizer>

Now, when you run the app, everything should look fine:

Syncing scrolling between two lists

Another useful component is ScrollSync.

For this example, you’ll need to return to the previous configuration that returns one short sentence:

text: loremIpsum({
count: 1,
units: 'sentences',
sentenceLowerBound: 4,
sentenceUpperBound: 8
})

The reason is that you cannot share a CellMeausure cache between two components, so you cannot have dynamic heights for the two lists I’m going to show next like in the previous example. At least not in an easy way.

If you want to have dynamic heights for something similar to the example of this section, it’s better to use the MultiGrid component.

Moving on, import ScrollSync:

import { List, AutoSizer, ScrollSync } from "react-virtualized";

And in the render method, wrap the div element with the list class in a ScrollSync component like this:

<ScrollSync>
  {({ onScroll, scrollTop, scrollLeft }) => (
    <div className="list">
      <AutoSizer>
      {
        ({ width, height }) => {
          return (
                  <List
                    width={width}
                    height={height}
                    rowHeight={rowHeight}
                    onScroll={onScroll}
                    rowRenderer={this.renderRow}
                    rowCount={this.list.length}
                    overscanRowCount={3} />
          )
        }
      }
      </AutoSizer>
    </div>
  )
}
</ScrollSync>

ScrollSync also takes a function as a child to pass some parameters. Perhaps the ones that you’ll use most of the time are:

  • onScroll. A function that will trigger updates to the scroll parameters to update the other components, so it should be passed to at least one of the child components.
  • scrollTop. The current scroll-top offset, updated by the onScroll function.
  • scrollLeft. The current scroll-left offset, updated by the onScroll function.

If you put a span element to display the scrollTop and scrollLeft parameters:

...
<div className="list">
<span>{scrollTop} - {scrollLeft}</span>
<AutoSizer>
...
</AutoSizer>
</div>

And run the app, you should see how the scrollTop parameter is updated as you scroll the list:

As the list doesn’t have a horizontal scroll, the scrollLeft parameter doesn’t have a value.

Now, for this example, you’ll add another list that will show the ID of each comment and its scroll will be synchronized to the other list.

So let’s start by adding another render function for this new list:

renderColumn({ index, key, style }) {
  return (
        <div key={key} style={style} className="row">
          <div className="content">
            <div>{this.list[index].id}</div>
          </div>
        </div>
  );
}

Next, in the AutoSizer component, disable the width calculation:

<AutoSizer disableWidth>
{
   ({ height }) => {
     ...
   }
}
</AutoSizer>

You don’t need it anymore because you’ll set a fixed width to both lists and use absolute position to place them next to each other.

Something like this:

<div className="list">
  <AutoSizer disableWidth>
  {
    ({ height }) => {
      return (
        <div>
          <div 
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
            }}>
              <List
                className="leftSide"
                width={50}
                height={height}
                rowHeight={rowHeight}
                scrollTop={scrollTop}
                rowRenderer={this.renderColumn}
                rowCount={this.list.length}
                overscanRowCount={3}  />
          </div>
          <div
            style={{
              position: 'absolute',
              top: 0,
              left: 50,
            }}>
              <List
                width={800}
                height={height}
                rowHeight={rowHeight}
                onScroll={onScroll}
                rowRenderer={this.renderRow}
                rowCount={this.list.length}
                overscanRowCount={3}  />
          </div>
        </div>
      )
    }
  }
  </AutoSizer>
</div>

Notice that the scrollTop parameter is passed to the first list so its scroll can be controlled automatically, and the onScroll function is passed to the other list to update the scrollTop value.

The leftSide class of the first list just hides the scrolls (because you won’t be needing it):

.leftSide {
overflow: hidden !important;
}

Finally, if you run the app and scroll the right-side list, you’ll see how the other list is also scrolled:

Conclusion

This article, I hope, showed you how to use react-virtualized to render a large list in an efficient way. It only covered the basics, but with this foundation, you should be able to use other components like Grid and Collection.

Of course, there are other libraries built for the same purpose, but react-virtualized has a lot of functionality and it’s well maintained. Plus, there is a Gitter chat and a StackOverflow tag for asking questions.

Remember that you can find all the examples in this GitHub repository.


Plug: LogRocket, a DVR for web apps

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single page apps.

Try it for free.


The post Rendering large lists with React Virtualized appeared first on LogRocket Blog.

Top comments (1)

Collapse
 
siddude2016 profile image
Siddharth

Hi Brian,
The tutorial is really awesome and easy to understand. But I want to do the same using a Table. I have a table with 10 columns and rows which I need to add dynamically based on the user scroll. I am able to do that thanks to your tutorial.

I have rows which have icons on them, which are changed dynamically based on the user click. When the user clicks it is supposed to change immediately. I am using backend services to call and update the data. I also have modals which change the data on the rows.

Can you guide me on how react-virtualized can be used for tables with dynamic icons or buttons in rows where they are controlled by data coming from backend?