How to implement Virtualized Grid/List in React

Around 2018, one of my colleague was working on creating a list component which only renders a limited amount of items in the list instead of the whole one to improve the performance if the list is in large or huge scale. I was always interested at how he did that, but I did not do any investment, just an idea. Then once I was asked how to implement such thing during an interview, it remindered me. I wrote it to my learning plan blog. After I began to work in the new company, I found all the lists in the product already used this idea with the library react-virtualized and its optimized version react-window. Finaly, I decided to learn this thing -- virtualized, figure out how it is implemented.

Nowadays virtualized has become a kind of standard for all the grid/list library, it's used in most frameworks or libraries,like mui, react-table. It helps improve the performance and user experience of large, complex, and data-intensive applications built with React. It does this by rendering only the items that are currently visible on the screen, and virtualizing the rest of the items, which allows the application to handle large datasets without negatively impacting performance or the user experienceSo here I would share my research about how it is implemented. Since I am more familiar with React, so I will use React as the framework.

Grid/List

Normally, both List and Grid are virtualized. But since List is actually an one-dimension Grid, so let's take Grid as an example.

Workflow

To implement this feature, we need to implement in two parts: javascript and html. With javascript, we need to calculate the start/end indexes of the visible elements. And for html, we need to paint them.

Javascript - logic

OK, let's clear the logic firstly. Let's imagine we have a 1000x1000 grid, only 20x20 are rendered in the table no matter how it scrolls. So to render only the visible items in the long list/grid during scrolling, it must be related to the scroll event. We need to

  1. calculate the scroll offset of the whole component when scroll
  2. calculate the start and end index of vertical and horizontal elements based on offset
  3. generate the visible element based on the indexes

Scroll offset

Apparently, we need a callback event function to bind to the scroll event of the root element, calculating the offsets in vertical and horizontal directions. It can be obtained from node's property scrollLeft and scrollTop.

 1const [verticalScroll, setVerticalScroll] = React.useState(0);
 2const [horizontalScroll, setHorizontalScroll] = React.useState(0);
 3
 4// set up scroll event to update the offset of top and left
 5const onScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
 6  const target = event.target as HTMLDivElement;
 7  const leftOffset = Math.max(0, target.scrollLeft);
 8  const topOffset = Math.max(0, target.scrollTop);
 9
10  setVerticalScroll(topOffset);
11  setHorizontalScroll(leftOffset);
12}, []);

Start/end index

OK, the offsets are here now. Naturally, the start and end indexes are easy to calculate with the size of cell and the window from input.

 1const useIndexForDimensions = ({
 2  offset,
 3  cellDimension,
 4  windowDimension,
 5}: DimensionsType) => {
 6  const startIndex = Math.floor(offset / cellDimension);
 7  const endIndex = Math.ceil((offset + windowDimension) / cellDimension);
 8  return [startIndex, endIndex];
 9};
10
11...
12
13// calculate the start and end row and column based on the offset
14const [verticalStartIdx, verticalEndIdx] = useIndexForDimensions({
15  offset: verticalScroll,
16  cellDimension: cellHeight,
17  windowDimension: inputWindowHeight,
18});
19
20const [horizontalStartIdx, horizontalEndIdx] = useIndexForDimensions({
21  offset: horizontalScroll,
22  cellDimension: cellWidth,
23  windowDimension: inputWindowWidth,
24});

Grid cell

After getting the index, we can just render the element within the range. Just simply slice the data array and pass the width and height to the cell element.

 1const useScrollItem = ({
 2  verticalStartIdx,
 3  verticalEndIdx,
 4  horizontalStartIdx,
 5  horizontalEndIdx,
 6  cellWidth,
 7  cellHeight,
 8  data,
 9}: ScrollItemType) =>
10  useMemo(() => {
11    return data.slice(verticalStartIdx, verticalEndIdx).map((row, i) => {
12      const rowChildren = row
13        .slice(horizontalStartIdx, horizontalEndIdx)
14        .map((_, j) => {
15          const vIdx = i + verticalStartIdx;
16          const hIdx = j + horizontalStartIdx;
17          let background = (vIdx + hIdx) % 2 === 1 ? "grey" : "white";
18          return (
19            <div
20              key={"row-" + vIdx + "-column-" + hIdx}
21              style={{
22                background,
23                color: "black",
24                display: "flex",
25                justifyContent: "center",
26                alignItems: "center",
27                width: cellWidth + "px",
28                height: cellHeight + "px",
29              }}
30            >
31              {vIdx}, {hIdx}
32            </div>
33          );
34        });
35
36      return (
37        <div key={"row-" + i} style={{ display: "flex" }} >
38          {rowChildren}
39        </div>
40      );
41    });
42  }, [
43    verticalStartIdx,
44    verticalEndIdx,
45    horizontalStartIdx,
46    horizontalEndIdx,
47    cellWidth,
48    cellHeight,
49    data,
50  ]);

Html part

So the logic part is finished. We also need to render it correctly in the html file. At first, to limit the component in the given size, we need a root element to set the width and height.

1<div
2  onScroll={onScroll}
3  style={{
4    width: `${inputWindowWidth}px`,
5    height: `${inputWindowHeight}px`,
6    overflow: "auto",
7    position: "relative",
8  }}
9>

You can see we bind the onScroll callback function on this root element, and also set the overflow as auto to allow the children elements scrollable.

Since we do not paint all the elements in the DOM, we must have something to meet two requirements at the same time.

  1. We need a child element with big enough size to make the root element scrollable. And its size should allow the visible element display correctly.
  2. This child element has no text to display, only has size.
1<div style={{
2    width: `${cellWidth * data[0].length}px`,
3    height: `${cellHeight * data.length}px`,
4  }}
5>

So here the width would be cell width multiple the length of the row and height would be the same. When we scroll the page, actually we are scrolling this non-text element.

Finally, we need to the parent node to display the visible elements. This is the core part, because when the previous invisible element scrolls, this child element would also have offset. To make sure it displays inside of the window, we need to do transform to it with the offset values calculated from the first step in javascript part.

1<div
2  style={{
3    position: "absolute",
4    transform: `translate(${horizontalScroll}px, ${verticalScroll}px)`,
5    display: "flex",
6    flexDirection: "column",
7  }}
8>

Deploy to Github pages

You can check the source code xfsnowind/react-virtualized-experiment, I also have deployed it to my blog Here.

Actually, I have already deployed my own blog website by Hugo in Github Pages, then how could I deploy this app to a subpage of the website without effecting Hugo. Check here to deploy and here to add command to github actions.

comments powered by Disqus