How to Virtualize Large Lists in React

Render only the rows visible in a scroll viewport with TanStack Virtual, so a list with thousands of items stays smooth.

6 min read

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.

bashbash
npm install @tanstack/react-virtual

The 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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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.

csscss
.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

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.
RunePowered by Rune AI

Frequently Asked Questions

Does React have built-in list virtualization?

No. React renders every element you return. Use a library such as TanStack Virtual to render only the rows inside the scroll viewport.

What is overscan?

Overscan is the number of extra rows rendered just outside the viewport. It prevents blank space when the user scrolls fast and keeps the cost small.

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.