Type something to search...
Virtualizing Long Lists with TanStack Virtual

Virtualizing Long Lists with TanStack Virtual

Render 10,000 rows with .map() and React creates 10,000 components, the browser creates tens of thousands of DOM nodes, and the first paint takes seconds. Scrolling stutters, memory climbs, and every state change that touches the list re-renders all of it. Memoization helps a bit, but you're still paying for DOM nodes the user can't see.

Virtualization (also called windowing) fixes this by rendering only the rows inside the visible area plus a small buffer. As the user scrolls, rows that leave the viewport are removed and new ones are rendered. The list looks and scrolls like it has 10,000 rows, but the DOM only ever holds a few dozen.

TanStack Virtual is a headless library for this. It does the math (which items are visible, where each one goes, how tall the whole list is) and leaves all the markup and styling to you. This post covers fixed-size lists, dynamic heights, window scrolling, grids, scrolling to an item, and infinite loading with TanStack Query.

Installing TanStack Virtual

npm install @tanstack/react-virtual

The React adapter exports two hooks: useVirtualizer for a scrollable container element and useWindowVirtualizer for lists that scroll with the page.

How Virtualization Works

Every virtualized list has the same three-layer structure:

  1. A scroll container with a fixed height and overflow: auto. This is the element that actually scrolls.
  2. An inner spacer with the full height of all items combined. It gives the scrollbar the right size and range.
  3. The visible items, absolutely positioned (or translated) inside the spacer at the offset where they'd be if every item were rendered.

The virtualizer watches the container's scroll position and size, and returns the list of items that should be on screen right now. You render only those.

A Basic Fixed-Size List

Here's a list of 10,000 rows, each 36 pixels tall:

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

const rows = Array.from({ length: 10_000 }, (_, i) => `Row ${i + 1}`);

export function VirtualList() {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 36,
    overscan: 5,
  });

  return (
    <div
      ref={parentRef}
      style={{ height: 400, overflowY: "auto", border: "1px solid #ddd" }}
    >
      <div
        style={{
          height: virtualizer.getTotalSize(),
          width: "100%",
          position: "relative",
        }}
      >
        {virtualizer.getVirtualItems().map((item) => (
          <div
            key={item.key}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              height: item.size,
              transform: `translateY(${item.start}px)`,
            }}
          >
            {rows[item.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

The options you'll use on almost every list:

  • count: the total number of items.
  • getScrollElement: a function returning the scrolling element. It's a function because the ref is null during the first render.
  • estimateSize: the size of an item by index, in pixels. For fixed-size rows, return the exact size.
  • overscan: how many extra items to render above and below the visible range. A few extra rows prevent blank flashes during fast scrolling.

Each virtual item gives you an index (which data item it is), a key (stable key for React), start (pixel offset), and size. Using transform: translateY is generally smoother than setting top, because the browser can move the element without recalculating layout.

Open DevTools and inspect the list while scrolling. You'll see around 20 row elements, no matter how far down you go.

Dynamic Row Heights

Real content is rarely uniform. Comments, chat messages, and product cards have different heights depending on their text. TanStack Virtual handles this by measuring each item after it renders.

Pass virtualizer.measureElement as a ref on each item and set data-index so the virtualizer knows which item was measured:

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

type Comment = { id: number; author: string; body: string };

export function CommentList({ comments }: { comments: Comment[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: comments.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80,
    overscan: 5,
  });

  const items = virtualizer.getVirtualItems();

  return (
    <div ref={parentRef} style={{ height: 600, overflowY: "auto" }}>
      <div
        style={{
          height: virtualizer.getTotalSize(),
          width: "100%",
          position: "relative",
        }}
      >
        <div
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            width: "100%",
            transform: `translateY(${items[0]?.start ?? 0}px)`,
          }}
        >
          {items.map((item) => {
            const comment = comments[item.index];
            return (
              <article
                key={item.key}
                data-index={item.index}
                ref={virtualizer.measureElement}
                style={{ padding: "12px 16px", borderBottom: "1px solid #eee" }}
              >
                <strong>{comment.author}</strong>
                <p style={{ margin: "4px 0 0" }}>{comment.body}</p>
              </article>
            );
          })}
        </div>
      </div>
    </div>
  );
}

This version uses a slightly different layout. Instead of absolutely positioning every item, it translates one wrapper to the first visible item's offset and lets the items flow normally inside it. Because the items are in normal flow, their natural height is what gets measured.

Tips for dynamic sizing:

  • Make estimateSize close to the average. A good estimate means less scrollbar jumping as real measurements replace estimates.
  • Don't set a fixed height on measured items. The measurement would just return your fixed value.
  • Watch out for images. An image without dimensions changes the row height after it loads. Set width and height attributes, or use aspect-ratio, so the row's height is stable on first paint. TanStack Virtual uses a ResizeObserver and will re-measure, but stable sizes avoid visible jumps.

Scrolling with the Window

Sometimes the list is the page, like a feed or search results, and you don't want a nested scroll box. Use useWindowVirtualizer, which listens to the window's scroll position:

import { useLayoutEffect, useRef, useState } from "react";
import { useWindowVirtualizer } from "@tanstack/react-virtual";

export function WindowList({ items }: { items: string[] }) {
  const listRef = useRef<HTMLDivElement>(null);
  const [offset, setOffset] = useState(0);

  useLayoutEffect(() => {
    setOffset(listRef.current?.offsetTop ?? 0);
  }, []);

  const virtualizer = useWindowVirtualizer({
    count: items.length,
    estimateSize: () => 48,
    overscan: 8,
    scrollMargin: offset,
  });

  return (
    <div ref={listRef}>
      <div
        style={{
          height: virtualizer.getTotalSize(),
          width: "100%",
          position: "relative",
        }}
      >
        {virtualizer.getVirtualItems().map((item) => (
          <div
            key={item.key}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              height: item.size,
              transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
            }}
          >
            {items[item.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

scrollMargin tells the virtualizer how far down the page the list starts, for example below a header. Item start values include that margin, so you subtract it when positioning items inside the list.

Horizontal Lists and Grids

Set horizontal: true to virtualize columns instead of rows. Sizes then refer to widths, and you position items with translateX.

For a grid, like a spreadsheet, combine two virtualizers that share the same scroll element:

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

const ROWS = 10_000;
const COLS = 200;

export function Grid() {
  const parentRef = useRef<HTMLDivElement>(null);

  const rowVirtualizer = useVirtualizer({
    count: ROWS,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 32,
    overscan: 5,
  });

  const colVirtualizer = useVirtualizer({
    horizontal: true,
    count: COLS,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 120,
    overscan: 3,
  });

  return (
    <div ref={parentRef} style={{ height: 500, width: 800, overflow: "auto" }}>
      <div
        style={{
          height: rowVirtualizer.getTotalSize(),
          width: colVirtualizer.getTotalSize(),
          position: "relative",
        }}
      >
        {rowVirtualizer.getVirtualItems().map((row) =>
          colVirtualizer.getVirtualItems().map((col) => (
            <div
              key={`${row.key}-${col.key}`}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: col.size,
                height: row.size,
                transform: `translate(${col.start}px, ${row.start}px)`,
                borderRight: "1px solid #f0f0f0",
                borderBottom: "1px solid #f0f0f0",
                fontSize: 13,
                padding: "6px 8px",
                boxSizing: "border-box",
              }}
            >
              R{row.index + 1}C{col.index + 1}
            </div>
          ))
        )}
      </div>
    </div>
  );
}

That's two million cells, and the DOM holds a couple of hundred at a time.

Scrolling to an Item

The virtualizer can scroll to any index, even one that has never been rendered:

<button onClick={() => virtualizer.scrollToIndex(4999, { align: "center" })}>
  Jump to row 5,000
</button>

align can be "start", "center", "end", or "auto" (scroll only as much as needed to bring it into view). This is useful for "jump to message," restoring a selection, or keyboard navigation. There's also scrollToOffset for scrolling to an exact pixel position.

With dynamic heights, rows that haven't been measured use their estimate, so the virtualizer corrects the position as real measurements come in. Expect the final position to settle after a frame or two on very long jumps.

Infinite Loading with TanStack Query

Virtualization pairs naturally with infinite scroll. The idea: add one extra "loader" row at the end, and when it becomes visible, fetch the next page. With TanStack Query's useInfiniteQuery:

import { useEffect, useRef } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";

type Page = { items: { id: number; title: string }[]; nextCursor: number | null };

async function fetchPage(cursor: number): Promise<Page> {
  const res = await fetch(`/api/posts?cursor=${cursor}`);
  if (!res.ok) throw new Error("Failed to load posts");
  return res.json();
}

export function InfinitePosts() {
  const parentRef = useRef<HTMLDivElement>(null);

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
    useInfiniteQuery({
      queryKey: ["posts"],
      queryFn: ({ pageParam }) => fetchPage(pageParam),
      initialPageParam: 0,
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    });

  const posts = data?.pages.flatMap((p) => p.items) ?? [];

  const virtualizer = useVirtualizer({
    count: hasNextPage ? posts.length + 1 : posts.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 56,
    overscan: 5,
  });

  const virtualItems = virtualizer.getVirtualItems();
  const lastItem = virtualItems[virtualItems.length - 1];

  useEffect(() => {
    if (!lastItem) return;
    if (lastItem.index >= posts.length - 1 && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [lastItem, posts.length, hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (status === "pending") return <p>Loading...</p>;
  if (status === "error") return <p>Could not load posts.</p>;

  return (
    <div ref={parentRef} style={{ height: 600, overflowY: "auto" }}>
      <div
        style={{
          height: virtualizer.getTotalSize(),
          position: "relative",
          width: "100%",
        }}
      >
        {virtualItems.map((item) => {
          const isLoader = item.index > posts.length - 1;
          return (
            <div
              key={item.key}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                height: item.size,
                transform: `translateY(${item.start}px)`,
              }}
            >
              {isLoader ? "Loading more..." : posts[item.index].title}
            </div>
          );
        })}
      </div>
    </div>
  );
}

The count includes the loader row while there are more pages. When the last rendered item reaches the end of the loaded data, the effect calls fetchNextPage. The isFetchingNextPage check prevents duplicate requests. For more on the query side, see Managing Server State with TanStack Query.

Accessibility Considerations

Virtualization removes off-screen items from the DOM, which has consequences:

  • Browser find (Ctrl+F) only searches rendered rows. If users need to search the list, provide a search or filter input.
  • Screen readers only see rendered items. Add role="list" and role="listitem" (or a table role structure), and use aria-setsize and aria-posinset on items so assistive tech can announce "item 512 of 10,000."
  • Keyboard focus can be lost when a focused row scrolls out and unmounts. For keyboard navigation, track the active index in state and call scrollToIndex before focusing.
<div
  role="listitem"
  aria-setsize={rows.length}
  aria-posinset={item.index + 1}
>
  {rows[item.index]}
</div>

Common Mistakes with TanStack Virtual

  • Forgetting the container height. The scroll element needs a fixed height (or a flex parent that constrains it) and overflow: auto. Without it, the container grows to fit the spacer and nothing is virtualized.
  • Using the array index as the React key. Use item.key, which is stable, or provide getItemKey when your items have IDs and the list can reorder.
  • Setting fixed heights on dynamically measured rows. measureElement reads the rendered size. Let content determine the height.
  • Missing data-index with measureElement. The virtualizer can't map the measurement to an item without it.
  • Virtualizing short lists. For a few hundred simple rows, plain rendering is fine and simpler. Virtualize when you have thousands of items or heavy rows.
  • Images without dimensions. Rows that change height after an image loads cause jumping. Reserve space with width, height, or aspect-ratio.

Frequently Asked Questions (FAQ) About TanStack Virtual

When rendering the full list causes noticeable lag on first paint, during scrolling, or when the list re-renders. In practice that's usually somewhere above one or two thousand simple rows, or a few hundred complex rows with images and interactive elements. Profile before and after to confirm the gain.

Both render only visible items. react-window ships ready-made list and grid components with fixed props. TanStack Virtual is headless: it gives you the positions and sizes and you write the markup, which makes dynamic heights, custom layouts, sticky rows, and window scrolling easier to build.

Yes. Provide an estimateSize close to the average, attach virtualizer.measureElement as the ref on each item, and add a data-index attribute. The virtualizer measures each row after it renders and updates positions and the total size.

Yes. Virtualize the table body rows and keep the header outside the virtualized area. Because tbody rows can't be absolutely positioned easily, a common approach is to render spacer rows above and below the visible rows with heights taken from the first item's start and the remaining total size.

Usually the scroll container has no fixed height, so it expands to fit everything and the virtualizer thinks all items are visible, or it has zero height and nothing is visible. Give the container an explicit height or put it in a flex layout that constrains it, and set overflow: auto.

It can, because only visible items are in the HTML. For content that should be indexed, render the first page of items normally on the server and provide paginated URLs. Virtualization is best for app-like interfaces such as dashboards, logs, chats, and admin tables.

Conclusion

TanStack Virtual renders only the items the user can see, which turns lists of tens of thousands of rows into a few dozen DOM nodes. You provide a scroll container, a spacer sized by getTotalSize(), and the items from getVirtualItems() positioned at their start offsets. Add measureElement for dynamic heights, useWindowVirtualizer for page-level scrolling, two virtualizers for grids, and a loader row with useInfiniteQuery for endless feeds.

Start with the slowest list in your app, confirm the problem with the Profiler, and replace the plain .map() with the basic fixed-size example. Once that works, add dynamic measurement or infinite loading as needed, and don't forget the accessibility attributes that let every user know how long the list really is.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading