Welcome back, intrepid developer! In our journey through the TanStack ecosystem, we’ve explored how to manage server state with Query, build dynamic interfaces with Table, and navigate complex applications with Router. But what happens when your beautifully crafted table suddenly needs to display thousands of rows, or your list component has tens of thousands of items? Performance can quickly grind to a halt, leading to sluggish UIs and frustrated users.

This chapter introduces you to TanStack Virtual, the headless superhero designed to tackle this exact problem. We’ll dive deep into the concept of UI virtualization, understand why it’s a game-changer for large datasets, and learn how to implement it step-by-step. By the end, you’ll be able to build lightning-fast lists and tables, even with massive amounts of data, ensuring a smooth and responsive user experience. Get ready to unlock a new level of frontend performance!

The Challenge of Large Datasets: Why Virtualization?

Imagine you have a list of 10,000 product items or a table with 5,000 user records. If you try to render all these items directly into the Document Object Model (DOM) at once, your browser will likely struggle. Here’s why:

  1. Memory Consumption: Each DOM element consumes memory. Thousands of elements can quickly exhaust available memory, especially on less powerful devices.
  2. Rendering Time: The browser has to calculate the layout, styles, and paint every single element. This process is computationally expensive and scales linearly with the number of elements.
  3. Reflows and Repaints: Interacting with the page (scrolling, resizing) or updating state can trigger expensive reflows (recalculating layout) and repaints (redrawing elements) for all visible elements, leading to jank and freezes.

Have you ever scrolled a page and felt it stutter or freeze momentarily? That’s often the symptom of rendering too many elements at once.

Virtualization is a technique that solves this by rendering only the items that are currently visible within the user’s viewport, plus a few extra items just outside the view for a smoother scrolling experience. As the user scrolls, the library dynamically swaps out the “virtual” items, recycling existing DOM elements and updating their content. This drastically reduces the number of elements the browser needs to manage at any given time.

Let’s visualize this concept:

flowchart TD A[Large Dataset] --> B{TanStack Virtual Library} B -->|Calculates visible range| C[Virtual Items Viewport] C --> D[Rendered DOM Elements] D --> E[User Sees Only Visible Items] E --> F{User Scrolls} F --> B

As you can see, the TanStack Virtual Library acts as a smart intermediary, ensuring that only a manageable subset of your data ever makes it to the DOM. It’s like having a tiny, efficient window into a much larger world!

Core Concepts of TanStack Virtual

TanStack Virtual is a headless library, meaning it provides the logic for virtualization without dictating your UI. You have complete control over how your virtualized list or table looks. This is consistent with the TanStack philosophy we’ve seen in Query and Table.

The core of TanStack Virtual revolves around a hook (or function, depending on your framework adapter) that gives you information about which items should be rendered. For React, this is the useVirtualizer hook from @tanstack/react-virtual.

Let’s break down its key aspects:

1. The useVirtualizer Hook (for React)

This hook is your entry point to virtualization. It takes a configuration object and returns an object containing virtualized items and utility functions.

Key Configuration Options:

  • count: This is the total number of items in your list or table. It’s crucial for the virtualizer to know the full extent of the scrollable area.
  • getScrollElement: A function that returns the DOM element responsible for scrolling. This is typically a ref to the container element that you want to be scrollable.
  • estimateSize: A function that estimates the size (height for vertical lists, width for horizontal) of each item. This is vital for the virtualizer to calculate scroll positions accurately. If your items have fixed sizes, this is straightforward. If they have dynamic sizes, you might need more advanced techniques or a good average estimate.
  • overscan: The number of items to render outside the visible viewport. A higher overscan value can make scrolling smoother by pre-rendering items before they fully enter view, but it also increases the number of DOM elements.
  • horizontal: A boolean flag to indicate if you’re virtualizing horizontally instead of vertically.

2. virtualItems

The useVirtualizer hook returns an array of virtualItems. These are not your actual data items. Instead, each virtualItem is an object that tells you:

  • index: The index of the item from your original count that should be rendered.
  • start: The pixel offset from the top (or left for horizontal) of the scroll container where this item should be positioned.
  • size: The calculated size (height/width) of this item.
  • measureElement: A ref callback function to attach to the rendered DOM element. This allows the virtualizer to accurately measure the actual size of items, especially useful for dynamic heights.

You’ll use these virtualItems to render your actual data, positioning them with inline styles (transform: translateY(start)) to achieve the virtualized effect.

Step-by-Step Implementation: Virtualizing a Simple List

Let’s build a simple React component that displays a large list of numbers, first without virtualization to see the problem, then with TanStack Virtual.

First, let’s make sure we have the necessary packages. As of 2026-01-07, the latest stable version of @tanstack/react-virtual is v3.

# Install TanStack Virtual for React
npm install @tanstack/react-virtual@latest
# or
yarn add @tanstack/react-virtual@latest

1. The Problem: A Non-Virtualized List

Create a new React component, NonVirtualizedList.jsx:

// src/components/NonVirtualizedList.jsx
import React from 'react';

const NonVirtualizedList = ({ itemCount = 10000 }) => {
  const items = Array.from({ length: itemCount }, (_, i) => `Item ${i}`);

  return (
    <div className="list-container" style={{ height: '400px', overflowY: 'scroll', border: '1px solid #ccc' }}>
      <h3>Non-Virtualized List ({itemCount} items)</h3>
      {items.map((item, index) => (
        <div key={index} className="list-item" style={{ padding: '10px', borderBottom: '1px solid #eee' }}>
          {item}
        </div>
      ))}
    </div>
  );
};

export default NonVirtualizedList;

Now, let’s use it in our App.jsx (or a similar main component):

// src/App.jsx (or equivalent)
import React from 'react';
import NonVirtualizedList from './components/NonVirtualizedList';
import VirtualizedList from './components/VirtualizedList'; // We'll create this next
import './App.css'; // For basic styling

function App() {
  return (
    <div className="App">
      <h1>TanStack Virtual Demo</h1>
      <p>Observe the performance difference when scrolling.</p>

      <div style={{ display: 'flex', gap: '20px' }}>
        <NonVirtualizedList itemCount={10000} />
        {/* We'll add VirtualizedList here later */}
      </div>
    </div>
  );
}

export default App;

And some basic CSS in App.css:

/* src/App.css */
body {
  font-family: sans-serif;
  margin: 20px;
  background-color: #f4f4f4;
}

.App {
  text-align: center;
}

.list-container {
  background-color: white;
  margin: 0 auto;
  width: 45%; /* Adjust for two lists side-by-side */
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.list-item {
  box-sizing: border-box; /* Include padding in element's total width and height */
  background-color: #f9f9f9;
}

.list-item:nth-child(even) {
  background-color: #eef;
}

Run your application. Try scrolling the non-virtualized list with 10,000 items. You’ll likely notice a significant lag, especially if you scroll quickly. The browser’s developer tools (Performance tab) will show high CPU usage and long rendering times. This is the problem TanStack Virtual solves!

2. Virtualizing the List with useVirtualizer

Now, let’s create VirtualizedList.jsx and apply TanStack Virtual.

// src/components/VirtualizedList.jsx
import React, { useRef } from 'react';
// Import the useVirtualizer hook for React
import { useVirtualizer } from '@tanstack/react-virtual';

const VirtualizedList = ({ itemCount = 10000 }) => {
  // 1. Create a ref for the scrollable container element
  const parentRef = useRef(null);

  // 2. Initialize the virtualizer hook
  const rowVirtualizer = useVirtualizer({
    count: itemCount, // Total number of items in our list
    getScrollElement: () => parentRef.current, // Function to get the scrollable DOM element
    estimateSize: () => 32, // Estimated height of each item (in pixels)
    overscan: 5, // Render 5 items above and below the visible area for smooth scrolling
  });

  // These are our actual data items (can be fetched from an API, etc.)
  const items = Array.from({ length: itemCount }, (_, i) => `Item ${i}`);

  return (
    <div
      ref={parentRef} // Attach the ref to our scrollable container
      className="list-container"
      style={{
        height: '400px',
        overflowY: 'auto', // Important: Make it scrollable!
        border: '1px solid #ccc',
      }}
    >
      <h3>Virtualized List ({itemCount} items)</h3>
      <div
        style={{
          height: rowVirtualizer.getTotalSize(), // Set the total height of the scrollable area
          width: '100%',
          position: 'relative', // Necessary for absolute positioning of virtual items
        }}
      >
        {/* Map over the virtualizer's virtualItems, NOT your data items directly */}
        {rowVirtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.index}
            // Position each virtual item using transform for performance
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`, // Use the virtualizer's calculated size
              transform: `translateY(${virtualItem.start}px)`, // Move item to its correct scroll position
              padding: '10px',
              borderBottom: '1px solid #eee',
              boxSizing: 'border-box',
              backgroundColor: virtualItem.index % 2 === 0 ? '#f9f9f9' : '#eef', // Apply alternating background
            }}
          >
            {items[virtualItem.index]} {/* Render the actual data item */}
          </div>
        ))}
      </div>
    </div>
  );
};

export default VirtualizedList;

Explanation of the code added:

  1. import { useVirtualizer } from '@tanstack/react-virtual';: We bring in the star of the show.
  2. const parentRef = useRef(null);: We create a React ref. This ref will be attached to the div that acts as our scrollable container. The virtualizer needs to know which element to observe for scrolling events.
  3. useVirtualizer(...):
    • count: itemCount: We tell the virtualizer that our conceptual list has itemCount total items.
    • getScrollElement: () => parentRef.current: This function provides the actual DOM element that the virtualizer will monitor for scrolling.
    • estimateSize: () => 32: We’re estimating that each item is 32 pixels tall. This is crucial for the virtualizer to calculate the total scrollable height and item positions. If your items have fixed heights, this should match exactly.
    • overscan: 5: This means TanStack Virtual will render 5 items above and 5 items below the currently visible range. This helps prevent flicker during fast scrolling.
  4. ref={parentRef}: We attach our ref to the outer div that will be the scroll container.
  5. overflowY: 'auto': This CSS property is essential! It makes the parentRef element scrollable, allowing the virtualizer to detect scroll events.
  6. height: rowVirtualizer.getTotalSize(): The inner div (which holds the virtual items) is given a height equal to the total estimated height of all itemCount items. This creates the conceptual scrollable area, making the scrollbar appear correctly.
  7. position: 'relative': This is needed because we’re going to absolutely position the individual virtual items within this container.
  8. rowVirtualizer.getVirtualItems().map(...): Instead of mapping over items directly, we map over the array returned by getVirtualItems(). This array contains only the virtualItem objects that are currently within the viewport (plus overscan).
  9. key={virtualItem.index}: It’s important to use the index from virtualItem as the key, as this uniquely identifies the original data item.
  10. position: 'absolute', top: 0, left: 0, transform: translateY(${virtualItem.start}px): This is the magic! Each virtual item is absolutely positioned within the relative parent. The transformY CSS property is used to move the item to its correct vertical position within the scrollable area. transform is generally more performant than top for animations and positioning because it doesn’t trigger layout recalculations for other elements.
  11. height: ${virtualItem.size}px``: We use the size provided by the virtualizer for the item’s height. This is especially useful if you have dynamic heights and TanStack Virtual has measured them.
  12. items[virtualItem.index]: Finally, inside the virtualized div, we render the actual data corresponding to virtualItem.index.

Now, update your App.jsx to include VirtualizedList:

// src/App.jsx (updated)
import React from 'react';
import NonVirtualizedList from './components/NonVirtualizedList';
import VirtualizedList from './components/VirtualizedList';
import './App.css';

function App() {
  return (
    <div className="App">
      <h1>TanStack Virtual Demo</h1>
      <p>Observe the performance difference when scrolling.</p>

      <div style={{ display: 'flex', justifyContent: 'space-around', gap: '20px' }}>
        <NonVirtualizedList itemCount={10000} />
        <VirtualizedList itemCount={10000} />
      </div>
    </div>
  );
}

export default App;

Refresh your browser. You should now see two lists. Scroll both and compare the performance. The virtualized list should scroll incredibly smoothly, even with 10,000 items, while the non-virtualized one will likely still be sluggish. This is the power of virtualization!

Mini-Challenge: Dynamic Row Heights

Our current VirtualizedList assumes a fixed estimateSize of 32px. What if your list items have variable heights (e.g., text content of varying length)?

Challenge: Modify the VirtualizedList component to handle dynamic row heights.

Hint:

  • useVirtualizer provides a measureElement property on each virtualItem. This is a ref callback that you should attach to the actual DOM element of your item.
  • When measureElement is attached, TanStack Virtual will automatically measure the item’s actual size and update its internal calculations.
  • You might need to adjust your estimateSize to be a function that returns an initial estimate, and then let measureElement refine it.

Try to implement this before looking at a potential solution!

Click for a Hint/Solution approach

For dynamic heights, TanStack Virtual needs to know the actual height of each rendered item. You provide this by attaching the measureElement ref callback from virtualItem to the root DOM element of your rendered item.

// Inside VirtualizedList.jsx, modify the map function:
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
  <div
    key={virtualItem.index}
    ref={rowVirtualizer.measureElement} // Attach the virtualizer's measureElement ref here
    // ... other styles ...
    style={{
      // ... existing styles ...
      // Make sure the height is not fixed by inline style here if it's truly dynamic.
      // The virtualizer will manage the transformY.
      height: 'auto', // Allow content to dictate height initially, virtualizer will measure
      // ... existing transformY style ...
    }}
  >
    {items[virtualItem.index]}
    {/* Add some dynamic content to make height vary */}
    <p style={{ fontSize: `${10 + (virtualItem.index % 10)}px` }}>
      This item has dynamic content and height! {Array(virtualItem.index % 5).fill('more text ').join('')}
    </p>
  </div>
))}

You’ll also want to make sure your estimateSize is a reasonable average, but the measureElement is the key for accurate dynamic sizing.

Integrating with TanStack Table

Virtualizing with TanStack Table is a powerful combination. TanStack Table handles all the complex table logic (sorting, filtering, grouping), and TanStack Virtual ensures that only the visible rows are rendered.

The integration is straightforward:

  1. TanStack Table provides the rows: Your table instance will give you an array of rows (after sorting, filtering, etc.)
  2. TanStack Virtual uses rows.length for count: You pass the rows.length to useVirtualizer.
  3. TanStack Virtual manages row rendering: You iterate over rowVirtualizer.getVirtualItems() and use table.getRow(virtualItem.index) to get the correct TanStack Table row object.

Here’s a conceptual snippet for integrating with TanStack Table:

// Conceptual snippet: Not a full runnable example, focuses on integration points
import React, { useRef } from 'react';
import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';

// Assume 'data' and 'columns' are defined elsewhere
// const data = ...;
// const columns = ...;

const VirtualizedTable = ({ data, columns }) => {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    // ... other TanStack Table options (sorting, filtering, etc.)
  });

  const { rows } = table.getRowModel(); // Get the processed rows from TanStack Table

  const parentRef = useRef(null);

  const rowVirtualizer = useVirtualizer({
    count: rows.length, // Use the total number of processed rows from TanStack Table
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40, // Estimate row height
    overscan: 10,
  });

  const virtualRows = rowVirtualizer.getVirtualItems();

  return (
    <div
      ref={parentRef}
      className="table-container"
      style={{ height: '500px', overflowY: 'auto', border: '1px solid #ccc' }}
    >
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th key={header.id} colSpan={header.colSpan}>
                  {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody
          style={{
            height: rowVirtualizer.getTotalSize(), // Total height of the scrollable tbody
            position: 'relative',
          }}
        >
          {virtualRows.map((virtualRow) => {
            const row = rows[virtualRow.index]; // Get the actual TanStack Table row object
            return (
              <tr
                key={row.id}
                data-index={virtualRow.index}
                ref={rowVirtualizer.measureElement} // For dynamic row heights
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: '100%',
                  height: `${virtualRow.size}px`,
                  transform: `translateY(${virtualRow.start}px)`,
                }}
              >
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
                ))}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
};

This pattern is extremely powerful, allowing you to build feature-rich and performant data grids for enterprise applications.

Common Pitfalls & Troubleshooting

  1. Incorrect getScrollElement:
    • Symptom: The virtualized list doesn’t scroll, or the scrollbar doesn’t appear/work correctly.
    • Cause: You haven’t correctly identified the DOM element that is actually scrolling. It must be an element with overflow: auto or overflow: scroll.
    • Fix: Double-check that parentRef.current (or whatever you’re returning from getScrollElement) points to the correct scrollable container. Ensure that container has an overflow style applied.
  2. Inaccurate estimateSize:
    • Symptom: Content jumps or flickers during scrolling, or the scrollbar length doesn’t accurately reflect the total list size.
    • Cause: Your estimateSize function is providing a value significantly different from the actual item heights.
    • Fix: For fixed-height items, ensure estimateSize matches the exact height. For dynamic items, provide a good average estimate and always use rowVirtualizer.measureElement on your rendered items to allow the virtualizer to correct itself.
  3. Missing position: relative or position: absolute:
    • Symptom: Items overlap, appear in the wrong order, or are not positioned correctly.
    • Cause: The CSS required for transformY positioning isn’t set up.
    • Fix: Ensure your immediate parent of the virtualized items has position: relative and each virtual item has position: absolute, top: 0, left: 0, and the transform: translateY(...) style.
  4. Performance issues within virtualized items:
    • Symptom: Scrolling is smooth, but individual items are slow to render or update.
    • Cause: The problem isn’t the number of items, but the complexity or inefficient rendering inside each item component.
    • Fix: Optimize the components rendered for each virtualItem. Use React.memo for components that don’t need to re-render often, avoid unnecessary calculations, and profile individual item components. Remember, virtualization only helps with the number of elements, not the complexity of those elements.

Summary

Congratulations! You’ve successfully navigated the world of UI virtualization with TanStack Virtual.

Here are the key takeaways from this chapter:

  • Virtualization is essential for maintaining performance and a smooth user experience when dealing with large lists or tables (hundreds to thousands of items).
  • TanStack Virtual is a headless library that provides the core logic for virtualization, giving you full control over your UI.
  • The useVirtualizer hook (for React) is your primary tool, requiring count, getScrollElement, and estimateSize to function.
  • You map over rowVirtualizer.getVirtualItems() and use transform: translateY() to position your actual data items efficiently.
  • Integrating TanStack Virtual with TanStack Table creates highly performant data grids.
  • Common pitfalls include incorrect scroll element references, inaccurate size estimates, and missing CSS positioning.

By leveraging TanStack Virtual, you can build applications that remain snappy and responsive, no matter how much data you throw at them. This is a critical skill for modern frontend development!

In the next chapter, we’ll explore TanStack Store, a flexible and reactive state management library that underpins much of the TanStack ecosystem, and how it can be used for client-side state alongside TanStack Query’s server-state management.


References

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.