Go to main content

Have you ever heard about virtualization? Do you know how it works under the hood?

If it's not the case or if you want to refresh your knowledge, let's do it!

All the logic will be extracted in a JS class, so that you can use it in your favorite library. At the end, I do it with React.


Virtualization is the fact to put in the DOM only elements that are displayed on the user screen. In reality, there are some elements before and after to have a smooth scroll.

Why do we do that? If you put too many elements in the DOM you can have some performance issues and a bad user experience due to:

  • a lot of DOM nodes in memory
  • style calculation and paint cost

Who uses that? This in a technic that is really used in the industry, often in combination with infinite scroll. For example sites like Twitter, Instagram or Reddit uses it.

Dev.to performance issue with 650 articles

For the first article we are going to do virtualization with items having the same height.

Before, going "deep" in the implementation, it's important to understand the strategy we gonna have.

Virtualization explanation

As you can see in the image, there are multiple things to understand:

  • container: it's the element that will contain the list.
  • visible items: they are the items that are currently visible to the user. They are in blue in the image.
  • invisible items present in the DOM: they are extra items that are currently not visible to the user, but are present on the DOM to have a smooth scroll.
  • invisible items: they are items that are in the list but not present in the DOM. They will be in the DOM if they are in the range of items to put in the DOM, because are in the two previous category, when scrolling.

Let's do some Maths, simple one don't be afraid, to calculate the first visible item index:

// Rounding down if the first item is half displayed
// for example
const firstVisibleIndex = Math.floor(scrollOffset / itemSize);

You see, nothing complicated. Let's do the same thing to get the last index:

// Rounding down if the last item is half displayed
// for example
const lastVisibleIndex = Math.floor(
  (scrollOffset + window.height) / itemSize
);

Now let's talk about extra items. As seen previously, most of the time we will add extra item before and after the display ones. It will improve the smoothness of the scroll and not display big white screen when scrolling fast.

So the first index of present element is:

// We do not want to have negative index
// So let's take the max of the calculation and 0
const firstPresentIndex = Math.max(
  firstVisibleIndex - extraItems,
  0
);

And the last index of present element is:

// We do not want to have an index superior to
// the maximum item number
// So let's take the min of the calculation and `itemNumber`
const lastPresentIndex = Math.min(
  lastVisibleIndex + extraItems,
  itemNumber
);

We will need to place the items that are presents manually in the list element. The solution that I chose is to set the list element with position: relative and the items with position: absolute.

If you are not used to relative / absolute positioning, here is a little image to explain it:

Relative/absolute positioning

For our virtualization, the items which are in absolute position, are positioned relatively to the list element (which have relative position) thanks to top or left css properties in function of the list layout.

Then the list will scroll inside the container thanks to overflow: auto.

The first thing to do is to set the list style:

const getListStyle = () => {
  const listSize = this.itemNumber * this.itemSize;

  if (this.isVerticalLayout()) {
    // When dealing with vertical layout
    // it's the height that we configure
    return {
      height: listSize,
      position: "relative",
    };
  }

  // Otherwise it's the width
  return {
    width: listSize,
    position: "relative",
  };
};

And now let's do a method to get an item style by its index:

const getItemStyle = (itemIndex) => {
  const itemPosition = itemIndex * this.itemSize;

  if (this.isVerticalLayout()) {
    // When dealing with vertical layout
    // the item is positioned with the
    // `top` property
    return {
      height: this.itemSize,
      width: "100%",
      position: "absolute",
      top: itemPosition,
    };
  }

  // Otherwise with the `left` property
  return {
    height: "100%",
    width: this.itemSize,
    position: "absolute",
    left: itemPosition,
  };
};


Let's implement all that we have seen previously in an FixedVirtualization class:

class FixedVirtualization {
  constructor({
    containerHeight,
    containerWidth,
    itemNumber,
    itemSize,
    extraItems,
    layout,
  }) {
    this.containerHeight = containerHeight;
    this.containerWidth = containerWidth;
    this.itemNumber = itemNumber;
    this.itemSize = itemSize;
    this.extraItems = extraItems;
    this.layout = layout;
  }

  isVerticalLayout = () => {
    return this.layout === "vertical";
  };

  getListStyle = () => {
    const listSize = this.itemNumber * this.itemSize;

    if (this.isVerticalLayout()) {
      // When dealing with vertical layout
      // it's the height that we configure
      return {
        height: listSize,
        position: "relative",
      };
    }

    // Otherwise it's the width
    return {
      width: listSize,
      position: "relative",
    };
  };

  getItemStyle = (itemIndex) => {
    const itemPosition = itemIndex * this.itemSize;

    if (this.isVerticalLayout()) {
      // When dealing with vertical layout
      // the item is positioned with the
      // `top` property
      return {
        height: this.itemSize,
        width: "100%",
        position: "absolute",
        top: itemPosition,
      };
    }

    // Otherwise with the `left` property
    return {
      height: "100%",
      width: this.itemSize,
      position: "absolute",
      left: itemPosition,
    };
  };

  getFirstItemIndex = (scrollOffset) => {
    return Math.max(
      Math.floor(scrollOffset / this.itemSize) -
        this.extraItems,
      0
    );
  };

  getLastItemIndex = (scrollOffset) => {
    return Math.min(
      Math.floor(
        (scrollOffset + this.containerHeight) /
          this.itemSize
      ) + this.extraItems,
      this.itemNumber
    );
  };
}

And here we go! Only one more step before we have something fully functional.


Now, we need to watch when the user is scrolling inside the container.

Let's simply add a listener on the scroll event of our container element:

// Just register an event listener on `scroll` event
// In React will be done inside a `useEffect` or
// directly with an `onScroll` prop
const onScroll = () => {
  // Update a state or retrigger rendering of items
  // In React will be done with a `useState` to set the offset
};
container.addEventListener("scroll", onScroll);

// You will probably have to add a `removeEventListener`

Now that we have the logic of virtualization extracted in FixedVirtualization and know that we need to re-render our items when scrolling in the container element, let's do it in React.

The API that I decided to do, is to expose a List component with the following props:

  • layout: the layout of our list, vertical or horizontal. By default vertical
  • containerHeight: the height of the container
  • containerWidth: the width of the container
  • itemNumber: the number of items that is in the list
  • itemSize: the size of an item. The height for vertical layout, otherwise the item width.
  • renderItem: a callback to render an item. The index of the item and the style to spread on the item will be passed as parameters.

This is how you will use it:

function App() {
  return (
    <List
      containerHeight={400}
      containerWidth={600}
      itemNumber={1000}
      itemHeight={50}
      renderItem={({ index, style }) => (
        <div
          key={index}
          style={{
            ...style,
            // Just put a border to see each item
            border: "1px solid black",
          }}
        >
          {index}
        </div>
      )}
    />
  );
}

And here is the implementation of the List component:

function List({
  renderItem,
  containerHeight,
  containerWidth,
  itemNumber,
  itemSize,
  layout = "vertical",
}) {
  const [fixedVirtualization] = useState(
    () =>
      new FixedVirtualization({
        containerHeight,
        itemNumber,
        itemSize,
        extraItems: 10,
        layout,
      })
  );
  // We put the offset in a state
  // And get the right items to display at each render
  // and their styles
  const [scrollOffset, setScrollOffset] = useState(0);

  const firstIndex =
    fixedVirtualization.getFirstItemIndex(scrollOffset);
  const lastIndex =
    fixedVirtualization.getLastItemIndex(scrollOffset);

  // Let's create an array of the items
  // which are present in the DOM
  const items = [];
  for (
    let index = firstIndex;
    index <= lastIndex;
    index++
  ) {
    items.push(
      renderItem({
        index,
        style: fixedVirtualization.getItemStyle(index),
      })
    );
  }

  // Let's create an `onScroll` callback
  // We `useCallback` it only to have a stable ref for
  // the throttling which is for performance reasons
  const onScroll = useCallback(
    throttle(250, (e) => {
      const { scrollTop, scrollLeft } = e.target;
      setScrollOffset(
        layout === "vertical" ? scrollTop : scrollLeft
      );
    }),
    []
  );

  return (
    <div
      style={{
        height: containerHeight,
        width: containerWidth,
        overflow: "auto",
      }}
      onScroll={onScroll}
    >
      <div style={fixedVirtualization.getListStyle()}>
        {items}
      </div>
    </div>
  );
}

You can play with the complete code with this sandbox:

In a following article, you will see how to manage when we have items with different height.


You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.