Have you ever encountered scenarios in your projects where you need to render large lists, such as big data tables or simply displaying multiple items in a list format? I remember facing a similar issue in my first job—I naively used map
to render a list with over ten thousand items, only to find the page becoming extremely sluggish and even crashing. The main reason is that rendering thousands of items all at once into the DOM forces the browser to spend a significant amount of time rendering these elements. During interactions, this also leads to severe performance issues due to repaint/reflow.
This article will explain the principles behind virtual lists and how to implement them.
Whether you're viewing data on a computer or a mobile device, there's a maximum visible height, so there's no need to render all the data into the DOM at once. Virtual lists address this exact problem. The core idea is to render only the data currently in the viewport, meaning only a small number of elements are rendered at any given time. Simultaneously, we simulate the total height of the entire list to achieve an effect similar to infinite scrolling. As the user scrolls through the list, new items entering the visible range are loaded in real-time, while old items leaving the visible range are removed.
Through the interactive demo above, you can better understand the underlying principles. By setting the number of items and the height of each item, you can simulate the relationship between the viewport and the entire list by adjusting the Scroll Position. For example, if the viewport only has space for 10 items, even if there are 100 items in total, only 10 items need to be rendered. The remaining 90 items are simulated, achieving an effect similar to infinite scrolling.
<div style="height: 500px; overflow: auto;">
<div style="height: 500000px;"></div>
</div>
From the interactive UI above, you can see that the most important aspect of implementing a virtual list is determining the start and end of the visible area, represented by startIndex
and endIndex
. These values are calculated as follows:
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleItemCount = Math.ceil(containerHeight / itemHeight);
const endIndex = startIndex + visibleItemCount;
Next, we need to calculate the actual position of each item to know where it should be placed in the list:
const itemPosition = itemIndex * itemHeight;
After calculating the start and end of the visible area and the position of each item, we then compute the total height of all the data:
const totalHeight = itemCount * itemHeight;
One final optimization technique is using overscan to improve list rendering. This involves rendering items just outside the visible area to prevent visual jumps when scrolling.
const overscanCount = 3;
const startWithOverscan = Math.max(0, startIndex - overscanCount);
const endWithOverscan = Math.min(itemCount - 1, endIndex + overscanCount);
Finally, we just need to add an onScroll
event to the parent container and update the scrollTop
value within it to implement a basic virtual list.
Code is hidden. Click "Expand code" to view.
import React, { useState, useMemo, useRef, useCallback } from 'react';
import { preinit } from 'react-dom';
const ITEM_HEIGHT = 40;
const VIEWPORT_HEIGHT = 200;
const TOTAL_ITEMS = 1000;
const TOTAL_HEIGHT = TOTAL_ITEMS * ITEM_HEIGHT;
const Item = ({ data }) => (
<div
className="h-full w-full rounded-md flex items-center justify-between px-4 font-bold text-sm text-white box-border bg-gradient-to-br from-green-500 to-green-600 dark:from-green-600 dark:to-green-700 border-2 border-green-700 dark:border-green-800"
>
<span>{data.name}</span>
<span className="text-xs opacity-80 font-normal">
{data.description}
</span>
</div>
);
const useVirtualList = ({
itemCount,
itemHeight,
containerHeight,
overscan = 3
}) => {
const [scrollTop, setScrollTop] = useState(0);
const [isScrolling, setIsScrolling] = useState(false);
const scrollElementRef = useRef(null);
const { startIndex, endIndex, totalHeight, visibleItems } = useMemo(() => {
if (itemCount === 0) {
return { startIndex: 0, endIndex: 0, totalHeight: 0, visibleItems: [] };
}
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const endIndex = Math.min(startIndex + visibleCount - 1, itemCount - 1);
const startWithOverscan = Math.max(0, startIndex - overscan);
const endWithOverscan = Math.min(itemCount - 1, endIndex + overscan);
const visibleItems = [];
for (let index = startWithOverscan; index <= endWithOverscan; index++) {
visibleItems.push({
index,
style: {
position: 'absolute',
top: index * itemHeight,
left: 0,
right: 0,
height: itemHeight,
}
});
}
return {
startIndex: startWithOverscan,
endIndex: endWithOverscan,
totalHeight: itemCount * itemHeight,
visibleItems
};
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
const handleScroll = useCallback((event) => {
const newScrollTop = event.currentTarget.scrollTop;
setScrollTop(newScrollTop);
setIsScrolling(true);
const timeoutId = setTimeout(() => setIsScrolling(false), 150);
return () => clearTimeout(timeoutId);
}, []);
return {
containerProps: {
ref: scrollElementRef,
onScroll: handleScroll,
style: {
height: containerHeight,
overflow: 'auto',
position: 'relative',
}
},
innerProps: {
style: {
height: totalHeight,
position: 'relative',
}
},
visibleItems,
isScrolling,
startIndex,
endIndex,
};
};
export default function App({ itemCount = 1000 }) {
preinit("https://cdn.tailwindcss.com", {as: "script"});
const items = useMemo(() =>
Array.from({ length: itemCount }, (_, i => ({
id: i,
name: `Item ${i + 1}`,
description: `This is item number ${i + 1}`,
}))
, [itemCount]);
const {
containerProps,
innerProps,
visibleItems,
} = useVirtualList({
itemCount: items.length,
itemHeight: 60,
containerHeight: 400,
overscan: 3,
});
return (
<div className="min-h-screen p-4 sm:p-8 flex items-center justify-center bg-bg-primary">
<div className="w-full max-w-4xl mx-auto">
<div className="rounded-xl shadow-lg dark:shadow-slate-900/50 p-6 sm:p-8 border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
<div className="space-y-4">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Virtual List</h1>
<div className="flex justify-center">
<div className="w-full border-2 border-blue-500 dark:border-blue-400 rounded-lg overflow-hidden shadow-inner bg-slate-50 dark:bg-slate-800">
<div {...containerProps} className="border-0 bg-transparent">
<div {...innerProps}>
{visibleItems.map(({ index, style }) => (
<div key={index} style={{...style, padding: '2px 8px'}}>
<Item data={items[index]} />
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
While the implementation above isn't suitable for production environments, it helps you understand the principles behind virtual lists. For production use, I recommend libraries like react-window or tanstack-virtual.