All Articles

Massively improve list performance with a sliding window

Long lists in HTML can get really slow, even on fast devices. Browsers can only display so many elements and calculate so many layout changes before they start to feel slow. Normal lists with thousands or even hundreds of items can push beyond these limits.

Sliding window to the rescue

A sliding window on a list is a great way to render only the items that are currently on screen. The items that are off screen are replaced by one spacer at the top and one spacer at the bottom. These spacers have dynamically set height attributes depending on how many items they are replacing.

Building a list with a sliding window

Building a custom windowed list isn't that hard and is well worth the performance improvements.

Create a file called WindowList.js and we'll add a new component that renders a top spacer, a list of visible items, then a bottom spacer.

// WindowList.js

import { useRef } from 'react'
import useWindowList from './useWindowList'

const WindowList = ({ data, renderItem, height, overflow = 10 }) => {
  const listRef = useRef()

  const {
    topRows,
    visibleRows,
    bottomRows
  } = useWindowList(listRef, data, height, overflow)

  // This is an array of only the visible items
  const items = data.slice(topRows, topRows + visibleRows)

  return (
    <div ref={listRef}>
      <div style={{ height: topRows * height }} />

      {
        items.map((item) => renderItem(item))
      }

      <div style={{ height: bottomRows * height }} />
    </div>
  )
}

export default WindowList

I added a hook called useWindowList to factor out the complicated calculations and keep the focus on the HTML structure necessary to build the sliding window.

You can see that the useWindowList hook returns a number for the topRows, visibleRows and bottomRows. We use these numbers to determine the height of the top spacer, the visible items to display in the middle, and the height of the bottom spacer.

Determining the visible rows

In this example, we are using the scroll position to determine the visible rows.

// useWindowList.js

import { useEffect, useState } from 'react'

const useWindowList = (listRef, data, height, overflow) => {
  const initialState = {
    topRows: 0,
    visibleRows: 1,
    bottomRows: 0
  }

  const [state, setState] = useState(initialState)

  // Update visible items on scroll, resize or data change
  useEffect(() => {
    updateList()

    window.addEventListener('scroll', updateList)
    window.addEventListener('resize', updateList)

    return () => {
      window.removeEventListener('scroll', updateList)
      window.removeEventListener('resize', updateList)
    }
  }, [data, height, overflow])

  const updateList = () => {
    const totalRows = data.length
    const topRows = getTopRows()
    const visibleRows = getVisibleRows(topRows, totalRows)
    const bottomRows = totalRows - topRows - visibleRows

    setState({
      topRows,
      visibleRows,
      bottomRows
    })
  }

  // Calculate top rows depending on item height
  const getTopRows = () => {
    const scrollY = document.scrollingElement.scrollTop
    const scrollTop = Math.max(scrollY - listRef.current.offsetTop, 0)

    let topRows = Math.floor(scrollTop / height) - overflow
    if (topRows < 0) topRows = 0

    return topRows
  }

  // Calculate visible rows depending on item height
  const getVisibleRows = (topRows, totalRows) => {
    const { clientHeight } = document.scrollingElement
    let visibleRows = Math.ceil(clientHeight / height) + (overflow * 2)

    if (topRows + visibleRows > totalRows) {
      visibleRows = totalRows - topRows
    }

    return visibleRows
  }

  return state
}

export default useWindowList

Our useEffect hook is used to initially update the row counts and to also update the row counts every time we scroll or resize the window.

The topRows count is calculated from the scrollTop of the window divided by the row height. This determines how many rows are hidden under the top of the scroll container. The visibleRows count is basically the window height divided by the row height, and the bottomRows count is the number of rows left without the topRows or visibleRows.

We also add in overflow to the top and bottom of the visibleRows. This overflow is a few extra rows that get displayed off screen, but are ready to be displayed immediately when you scroll.

Using the WindowList

To use the WindowList component, we just need to pass in a collection to the data prop, a renderItem function and the height in pixels of the items.

<WindowList
  data={data}
  renderItem={renderItem}
  height={50}
  overflow={5}
/>

The renderItem function simply receives an item from the data collection and you can return any JSX you want.

const renderItem = (item) => {
  return (
    <div key={item.id}>{item.name}</div>
  )
}

Conclusion

Lists with sliding windows really do maximize performance at any scale. Creating a list with a sliding window can seem complicated, but when you break it down into its basic elements of a spacer, a list of visible items, and another spacer, it really isn't that hard to implement by hand and customize.