Rendering a list of a few dozen items is usually straightforward. Rendering thousands of rows, however, can increase DOM size, consume memory, and make scrolling less responsive.
Virtualization addresses this problem by rendering only the items that are visible, along with a small buffer around them.
Why Large Lists Become Slow
Consider a component that renders every result:
function ProductList({ products }) {
return (
<ul>
{products.map(product => (
<li key={product.id}>
{product.name}
</li>
))}
</ul>
);
}
This is a good solution for a reasonably sized collection. If products contains tens of thousands of entries, the browser must manage a very large number of DOM elements.
The cost is not limited to React rendering. Layout, painting, accessibility-tree processing, and memory consumption can also become significant.
Understand Virtualization
A virtualized list calculates which items belong in the visible viewport and renders only those items.
For a list containing 10,000 rows, the interface might render a few dozen at a time rather than creating all 10,000 DOM elements.
As the user scrolls, the rendered window moves through the data.
Virtualization is especially useful for:
- Large tables.
- Log viewers.
- Search results.
- Activity feeds with many entries.
- Administrative interfaces with large datasets.
Use a Dedicated Library
Virtualization involves more than hiding elements outside the viewport. A library needs to account for scroll position, item dimensions, container resizing, and the positioning of rendered rows.
A library such as TanStack Virtual can provide the underlying mechanics while allowing you to control the rendered markup.
A simplified integration looks like this:
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
});
return (
<div
ref={parentRef}
style={{ height: 400, overflow: "auto" }}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
}}
>
{virtualizer.getVirtualItems().map(row => (
<div
key={items[row.index].id}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: row.size,
transform: `translateY(${row.start}px)`,
}}
>
{items[row.index].name}
</div>
))}
</div>
</div>
);
}
This example assumes fixed-height rows and stable item IDs. Variable-height content requires more careful measurement and testing.
Keep Item Identity Stable
Use stable keys based on the identity of the underlying item, not its current position in the rendered window.
Index-based keys can cause state to appear attached to the wrong item when rows are inserted, removed, or reordered.
Also remember that virtualization can unmount rows as they leave the visible window. Do not keep essential unsaved form state only inside a row component if that component may be removed from the tree.
Consider Accessibility
A virtualized list exposes only a portion of its items to the DOM at any one time. Keyboard navigation, screen-reader behavior, focus management, and total item counts therefore deserve attention.
Test the actual interaction patterns of your application rather than assuming a visually correct list is automatically accessible.
For simpler lists, ordinary pagination may be easier to understand and more accessible.
Measure Before Adding Complexity
Virtualization is not necessary for every list. It adds implementation complexity and may complicate selection, focus, and dynamic row sizing.
Measure rendering performance and DOM size first. If the collection is small, a normal list with efficient rendering may be the better option.
Conclusion
Virtualized lists improve performance by limiting how many elements exist in the DOM at one time. Use a well-maintained library, stable keys, and appropriate accessibility testing.
The right approach depends on both dataset size and the interactions users need to perform.