Demistifying list virtualization in React

Tags:
  • React
  • Frontend
  • Performance

Published

A walkthrough of minimal implementation of a virtualized list component in React


What do we mean with list virtualization?

In the context of frontend development, list virtualization is a technique we can use to improve performance when rendering large lists of items. I say list, but the same idea applies to grids or any other layout you can think of.

In the spirit of Advent of Code, imagine you're tasked by Santa to create a web application to list the address of all children of the world, so he can deliver gifts. Assume Santa has access to the same mobile technology we have.

You take the list of all children of the world, you slap it in a for loop and render a simple HTML element for each one of them:

<span>
  {child.name}:
  <address>{child.address}</address>
</span>

A quick search online tells me there are roughly two billion children in the world. So your naive implementation will result in four billion DOM elements and Santa's mobile browser will explode.

You could implement a paginated list instead, but Santa wears gloves and has fat fingers so he prefers scrolling.

The solution to this problem is virtualization: by detecting the scroll position on the viewport you can determine which items should be visible.

Here's a more concrete example. Since I don't have, and I don't want to have, a list of children with their location and Santa hasn't provided staging data, I grabbed a few names off the List of Italian Philosophers on Wikipedia.

The virtualized list only renders the content currently visible in the viewport.

React virtualization in production

There are many established libraries that abstract away the building blocks of virtualized lists. If you're looking for production ready libraries for React, check out (in alphabetical order):

An implementation from scratch

This time, however, I'm not going to use any libraries as I want to show that it's actually a relatively simple concept. So here's an implementation of a virtualized list from scratch.

You can find a more complete version of this component in the companion repository.

type Props<T> = {
  height: number;
  rowHeight: number;
  gap?: number;
  data: T[];
  renderItem: (datum: T, translateY: number) => React.ReactNode;
  overscan?: number;
};

function VirtualList<T>({
  // this could also be determined dynamically
  // from the clientHeight
  height = 500,
  rowHeight,
  gap = 0,
  overscan = 10,
  data,
  renderItem,
}: Props<T>) {
  const containerRef = useRef<HTMLUListElement>(null);

  // when the scroll position changes, rerender the component
  // the function is throttled to avoid rerendering
  // too many times while scrolling
  const [scrollPosition, setScrollPosition] = React.useState(0);
  const onScroll = React.useCallback(
    throttle(function () {
      setScrollPosition(containerRef.current.scrollTop);
    }),
    [],
  );

  // calculate the start and end of the current window,
  // based on the scroll position and the desired overscan
  const startIndex = Math.max(
    Math.floor(scrollPosition / rowHeight) - overscan,
    0,
  );
  const endIndex = Math.min(
    Math.ceil(
      (scrollPosition + containerHeight) / rowHeight - 1,
    ) + overscan,
    data.length - 1,
  );

  const children = React.useMemo(() => {
    const visibleChildren = [];
    // only render the items that are currently visible
    for (let index = startIndex; index <= endIndex; index++) {
      const translateY = index * rowHeight + index * gap;
      // it's important that the rendering function
      // applies the `transform: translateY(...)`
      // style to each item, otherwise they won't be
      // visible.
      // Since styling is going to be different
      // in every application (styled components, tailwind, etc.)
      // we pass the value along and let users
      // decide how to style elements.
      visibleChildren.push(renderItem(data[index], translateY));
    }
    return visibleChildren;
  }, [rowHeight, startIndex, endIndex, gap, data]);

  return (
    <ul
      ref={containerRef}
      onScroll={onScroll}
      // It's important that the container has relative
      // positioning so the children elements can be translated
      // correctly.
      // It's also important to have a set height so the
      // container scrolls.
      // In a real world scenarion this would be a
      // component prop.
      className="relative h-[500px]"
    >
      {/**
       * this inner element has the same height that a
       * non-virtualized list would have. This makes it
       * clearer that the list can scroll and it makes
       * it clearer how long the list is.
       * */}
      <div style={{ height: data.length * rowHeight }}>
        {children}
      </div>
    </ul>
  );
}

That's it! This is, give or take, what all those complex libraries do behind the scenes.

Here's a demo I recorded of this component in action. Notice the number of elements rendered in the DOM.