To virtualize large lists in React, render only the rows that are visible in the scroll viewport, plus a small buffer, instead of every row in the data. React has no built-in list virtualization, so this article uses TanStack Virtual, a headless library that tracks scroll position for you.
Why rendering every row is slow
Rendering ten thousand rows creates ten thousand DOM nodes and re-renders all of them when the list updates. Even when the browser can handle it, scrolling and typing become janky because every row participates in layout and paint. Virtualization keeps the DOM to a few dozen rows no matter how large the data is.
The browser must style, layout, and paint each of those nodes. On a low-end phone, ten thousand nodes can push frame time well past the sixteen millisecond budget that keeps scrolling at sixty frames per second.
Install TanStack Virtual
Add the package to your project.
npm install @tanstack/react-virtualThe library is headless, so it provides the math but leaves the markup and styling to you. That keeps it flexible across different row heights, grids, and scroll containers.
The virtualizer watches the scroll position and reports which rows intersect the viewport, plus the overscan buffer. As the user scrolls, it swaps which rows are rendered instead of moving existing nodes.
Set up the virtualizer
Inside a component, create a ref for the scroll element and call the useVirtualizer hook. The rows prop holds your full data array.
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 5,
});The count option is the total number of rows. estimateSize returns the fixed row height in pixels, and overscan tells the library to keep five extra rows rendered above and below the viewport.
The parentRef is attached to the scroll container in the markup below, so getScrollElement always points at the element whose scroll position drives the visible slice.
getVirtualItems returns the slice of rows to render now, and each entry exposes its index, size, start offset, and key. getTotalSize returns the full scrollable height, which keeps the scrollbar proportional to the real list length.
Render the visible rows
Back in the same component, return the scroll container, a spacer sized to the full list, and the virtual rows. The inner div is the spacer, and its height equals the whole list, which keeps the scrollbar correct even though only a handful of rows exist in the DOM.
return (
<div ref={parentRef} className="list-viewport">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
className="row"
style={{ transform: `translateY(${item.start}px)` }}
>
{rows[item.index].name}
</div>
))}
</div>
</div>
);Each virtual item carries its index, its size, and a start offset. The absolute position comes from CSS, while the transform pushes each row to its correct slot. getTotalSize returns the height of the full list, so the spacer keeps the scrollbar accurate.
.list-viewport {
height: 400px;
overflow: auto;
}
.row {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 35px;
}The user sees a normal scrollbar that reflects ten thousand rows, while the DOM only contains the rows near the viewport. Scrolling updates the visible slice instead of moving every node.
By default, item.key is just the row's index, which is fine for a static list. If the rows can be filtered, sorted, or reordered, pass a getItemKey option to useVirtualizer that returns each row's real id instead, the same rule that applies to any React list key.
If rows can vary in height, pass a measureElement ref to the virtualizer instead of a fixed estimateSize, and it learns each row's real height as it renders. The same hook handles horizontal lists by passing horizontal: true, and two virtualizers together cover a grid of rows and columns.
When to reach for it
Virtualization solves one specific problem: a list with many items that is slow to render and scroll. It also changes the DOM, so test keyboard scrolling and screen reader navigation on the virtualized list. A few hundred rows rarely need it, and skipping virtualization keeps the markup simple.
Measure first with the Profiler to confirm the list is the bottleneck. Pair it with code splitting when the list and its data are one feature among many, and follow the measure-first workflow before changing code.
Rune AI
Key Insights
- Virtualization renders only the rows near the viewport.
- Use TanStack Virtual via the useVirtualizer hook.
- A spacer div sized with getTotalSize keeps the scrollbar accurate.
- estimateSize sets the row height and overscan controls the buffer.
- Measure a slow list in the Profiler before reaching for virtualization.
Frequently Asked Questions
Does React have built-in list virtualization?
What is overscan?
Conclusion
Virtualization keeps a long list fast by rendering only the rows near the viewport. TanStack Virtual tracks the scroll position and reports which items to render, and a spacer keeps the scrollbar the correct height.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.