Articles

May 31, 2023

Virtual Lists are Beautiful

There comes a time in every frontend dev's career when they will need to render large amounts of data in list form. The simple approach is to loop through all items and create the HTML for each. That can be thousands of elements when you have a lot of data, so it's better to show only a subset of those items through a process called virtualization. Using a virtual list allows you to render the data without making your site feel laggy and resource intensive. The goal is to render only the visible portion of the list of items and dynamically swap items in and out of the DOM as the user scrolls. Doing so allows for a more efficient rendering process and better user experience.


How to make one

To operate this implementation of a virtual list, there are a few limitations you should know. Each element in the list needs to be the same height and the height of the virtual list needs to be defined outside of the virtual list component (we'll go over a nifty trick for this at the end). With that in mind, here are the variables that need to be passed into the component:

numItems

Number of elements that are in the list

itemHeight

Height of the element in pixels

windowHeight

Height of the wrapping element in pixels

renderItem({ index, style })

Function that accepts the element index and style and returns a JSX element

overscan

Number of elements above or below the screen that will pre-render (helps when scrolling really fast)


Prop Types

With those parameters, we're able to construct our virtual list. If we were to write them as TypeScript props for a React virtual list component, it would look something like this:

type Props = {
  numItems: number
  itemHeight: number
  windowHeight: number
  renderItem: ({ index, style }: { index: number; style: React.CSSProperties }) => JSX.Element
  overscan?: number
}

Internal State

The virtual list itself only needs to keep track of one variable: scrollTop. This is the number of pixels that an element's content is scrolled vertically. The initial value is 0 and would look like this:

const [scrollTop, setScrollTop] = useState(0)

Calculate Starting and Ending Elements

The next step is to calculate the starting and ending indexes for the elements that should render. The starting index is found by taking the scroll top position and dividing it by the height of the elements to get the index of the element at the top of the scroll container. From there all you do is subtract the overscan variable and you have the starting index to render. To get the ending index you add the scroll top position to the height of the scroll container and divide that by the height of the elements. Add the overscan this time and boom, there's your end index.

const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)

const endIndex = Math.min(numItems - 1, Math.floor((scrollTop + windowHeight) / itemHeight) + overscan)

Create List of Elements for Render

With our newly created index variables, we now know which range of elements to render. To do that, we can use a simple for loop and call the renderItem function being passed into the virtual list component. We make each element positioned absolutely and calculate its top position by its index in the list. This should return a JSX element and we add that to the items array to prep them for rendering.

const items: JSX.Element[] = []

for (let i = startIndex; i <= endIndex; i++) {
  items.push(
    renderItem({
      index: i,
      style: { position: 'absolute', top: `${i * itemHeight}px`, width: '100%' },
    })
  )
}

Render the elements

The JSX markup for the virtual list is quite minimal. You need an outer scrolling element and an inner element whose height is calculated by the total number of list elements multiplied by the height of the elements. The array of elements being rendered can be added directly into the markup as it's just an array of JSX elements.

const innerHeight = numItems * itemHeight
<div className="scroll" style={{ overflowY: 'scroll', width: '100%' }} onScroll={onScroll}>
  <div className="inner" style={{ position: 'relative', height: `${innerHeight}px` }}>
    {items}
  </div>
</div>

You might have noticed that there is an onScroll property on the outer scrolling element. This is to track the scrollTop of the outer element and update the scrollTop state.

const onScroll = ({ currentTarget }) => setScrollTop(currentTarget.scrollTop)

Fully Assembled

When we put all those steps together, you get a reusable component that will help you render looooong lists of data. There are limitations with this simple setup but you can modify it to fit your needs. Here is the code to do so:

import React, { useState } from 'react'

type Props = {
  numItems: number
  itemHeight: number
  windowHeight: number
  renderItem: ({ index, style }: { index: number; style: React.CSSProperties }) => JSX.Element
  overscan?: number
}

const VirtualList = (props: Props) => {
  const { numItems, itemHeight, renderItem, windowHeight, overscan = 3 } = props

  const [scrollTop, setScrollTop] = useState(0)

  const innerHeight = numItems * itemHeight
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
  const endIndex = Math.min(numItems - 1, Math.floor((scrollTop + windowHeight) / itemHeight) + overscan)

  const items: JSX.Element[] = []

  for (let i = startIndex; i <= endIndex; i++) {
    items.push(
      renderItem({
        index: i,
        style: { position: 'absolute', top: `${i * itemHeight}px`, width: '100%' },
      })
    )
  }

  const onScroll = ({ currentTarget }) => setScrollTop(currentTarget.scrollTop)

  return (
    <div className="scroll" style={{ overflowY: 'scroll', width: '100%' }} onScroll={onScroll}>
      <div className="inner" style={{ position: 'relative', height: `${innerHeight}px` }}>
        {items}
      </div>
    </div>
  )
}

export default VirtualList

Using the Virtual List

Since the virtual list needs its height defined, you can wrap it in an element with a React ref and use the clientHeight of the ref to make the height responsive to whatever the height is of its wrapping element.

The element being rendered is found by referencing the index in the list of all data and the styles are added inline on the list element itself.

<div ref={virtualListContainer}>
  <VirtualList
    numItems={allData.length}
    itemHeight={30}
    windowHeight={virtualListContainer.current?.clientHeight || 0}
    renderItem={({ index, style }) => (
      <div key={allData[index].id} style={style}>
        {allData[index].title}
      </div>
    )}
  />
</div>

Hope you enjoyed reading through this article. If you happen to need a virtual list for Qwik, here's my virtual list implementation - it includes a few other added features that make for a nice utility component.