Using Tanstack Virtual and window virtualisation for a grid of items
Thursday, 13 March 2025Recently, I worked on a project that required rendering a large grid of items with varying row heights while keeping the page highly performant and easily filterable - without pagination. We noticed that as the number of items grew, page navigation and filtering became sluggish, resulting in a janky user experience. To address this, we implemented virtualization to render only the items visible in the viewport dynamically. After exploring various options, I chose Tanstack Virtual’s window virtualiser.
As it stands, the docs didn’t accommodate this use case, and I feel like a lot of others could potentially benefit from a brief guide on how to use the window virtualiser to render a grid of items.
The approach
Ok, so how do we approach this? We need both rows and columns, and structurally we need the columns to sit within each row. BUT we also need the columns to be responsive; we don’t want a four-column grid at mobile sizes, we want to scale it up or down depending on the width of the viewport. So breaking that down structurally, we need to:
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
return (
<div
ref={rowVirtualizer.measureElement}
>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => {
return (
<div key={virtualColumn.key}>
<YourGridItem />
</div>
);
})}
</div>
);
})}
Now to get the main idea across, I have removed a lot of the boilerplate code that you would need to get this working, but the main idea is that you have a row virtualiser and a column virtualiser. The row virtualiser is responsible for rendering the rows, and the column virtualiser is responsible for rendering the columns in each row.
Building it up a little more
So now we have the basic ideas in place, we need to add some more logic to get this working. We need to add the virtualisers themselves and the logic to pull in the right item for each grid position.
// We will cover the need for this in the next step
const [columns, setColumns] = useState<number>(4);
// An important anchor point so the virtualiser knows where to position itself in relation to the document
const parentRef = useRef<HTMLDivElement>(null);
// Figure out how many rows we need, Max.ceil will give us the correct number of rows
const rowsLength = Math.ceil(gridData.length / columns);
const rowVirtualizer = useWindowVirtualizer({
count: rowsLength,
// We need to estimate the size of the row, this is important for the virtualiser to know how much initial space to allocate
estimateSize: () => 340,
overscan: 8,
// Using the ref above to get the offset of the parent element
scrollMargin: parentRef.current?.offsetTop ?? 0,
gap: 16,
initialMeasurementsCache: measurementsCaches.get(pathname),
});
const columnVirtualizer = useWindowVirtualizer({
horizontal: true,
count: columns,
// We need to estimate the size of the column, this is important for the virtualiser to know how much initial space to allocate
estimateSize: () => 250,
overscan: 8,
gap: 16
});
return (
<div ref={parentRef} className='w-full'>
<div
className='w-full relative will-change-transform'
{/* This will intially use our estimated sizes to calculate the parent height */}
style={{
height: `${rowVirtualizer.getTotalSize()}px`
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
return (
<div
{/* The key and data-index are super important here */}
key={virtualRow.key}
data-index={virtualRow.index}
{/* Allows the virtualiser to measure the element */}
ref={rowVirtualizer.measureElement}
{/* Turns each row into a grid, some nice responsive benefits here and height management */}
className='grid gap-4 w-full absolute top-0 left-0 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
{/* Positions each element */}
style={{
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`
}}
>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => {
{/* Get our grid item data for each spot */}
const itemData = gridData[virtualRow.index * columns + virtualColumn.index];
{/* If there is no grid item (or the number of items does not fill the whole grid) return null */}
if (!contactData) {
return null;
}
return (
{/* Again important to have our key and data-index */}
<div key={virtualColumn.key} data-index={virtualColumn.index} ref={columnVirtualizer.measureElement}>
<YourGridItem itemData={itemData} />
</div>
);
})}
</div>
);
})}
</div>
</div>
);
Ok, so now we have added both of the virtualisers, and I’ve added some comments in there to give some more context around why we need some of these elements.
It’s worth noting that this approach was influenced by a discussion I came across in the Tanstack Virtual GitHub, mainly the introduction of grid (which works pretty great).
Adding responsiveness
Now we have the majority of the approach in place, the one thing we are missing is the responsiveness. We need to make sure that our grid is responsive and scales up or down depending on the viewport size. We do this by altering the column state variable when we hit certain points in the viewport. You might be thinking, “Well, CSS grid already handles this for us,” and you would be right, but we need to make sure that the virtualiser knows how many columns we have and how many rows we need to render.
Our media query enum (could also be a constant):
// These values can be whatever you want
export enum MediaQuerySizes {
SM = 640,
MD = 840,
LG = 1024,
XL = 1280,
'2XL' = 1536
}
And our updated component:
const [columns, setColumns] = useState<number>(4);
const parentRef = useRef<HTMLDivElement>(null);
const rowsLength = Math.ceil(gridData.length / columns);
const rowVirtualizer = useWindowVirtualizer({
count: rowsLength,
estimateSize: () => 340,
overscan: 8,
scrollMargin: parentRef.current?.offsetTop ?? 0,
gap: 16,
initialOffset: savedOffsets.get(pathname) ?? 0,
initialMeasurementsCache: measurementsCaches.get(pathname),
onChange: (virtualizer) => {
if (!virtualizer.isScrolling) {
savedOffsets.set(pathname, virtualizer.scrollOffset ?? 0);
measurementsCaches.set(pathname, virtualizer.measurementsCache);
}
}
});
const columnVirtualizer = useWindowVirtualizer({
horizontal: true,
count: columns,
estimateSize: () => 250,
overscan: 8,
gap: 16
});
const updateColumns = () => {
if (window.matchMedia(`(min-width: ${MediaQuerySizes.LG}px)`).matches) {
setColumns(4);
} else if (window.matchMedia(`(min-width: ${MediaQuerySizes.MD}px)`).matches) {
setColumns(3);
} else if (window.matchMedia(`(min-width: ${MediaQuerySizes.SM}px)`).matches) {
setColumns(2);
} else {
setColumns(1);
}
};
useEffect(() => {
updateColumns();
window.addEventListener('resize', updateColumns);
return () => window.removeEventListener('resize', updateColumns);
}, []);
return (
<div ref={parentRef} className='w-full'>
<div
className='w-full relative will-change-transform'
style={{
height: `${rowVirtualizer.getTotalSize()}px`
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className='grid gap-4 w-full absolute top-0 left-0 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
style={{
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`
}}
>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => {
const itemData = gridData[virtualRow.index * columns + virtualColumn.index];
if (!itemData) {
return null;
}
return (
<div key={virtualColumn.key} data-index={virtualColumn.index} ref={columnVirtualizer.measureElement}>
<YourGridItem itemData={itemData} />
</div>
);
})}
</div>
);
})}
</div>
</div>
);
Conclusion
And that’s it! By following this guide, you should be able to render out a virtualised grid of items and improve page performance! I’ve personally noticed the biggest difference in Safari, but even in Chrome, there is a significant improvement.
Tip:
If you have a search filter, pass in the filtered items instead of all of the grid items, and it will recalculate for you (which is kinda nice).
If you have any questions or think this could be improved in any way, please let me know. As always, it’s a pleasure to try and help out.