Skip to main content
  1. Frontends/
  2. React Guides/

Rendering Millions: Mastering List Virtualization with TanStack Virtual in React

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

It’s 2025. Browsers are faster, JavaScript engines are marvels of engineering, and devices have more RAM than the servers we used a decade ago. Yet, one thing remains painfully consistent: if you try to shove 10,000 DOM nodes into a webpage at once, the browser will choke.

We’ve all been there. The backend team hands you an API endpoint returning a “reasonable” dataset of a few thousand records. You map over the array, render a component for each, and suddenly your smooth React application turns into a slideshow. Scrolling feels like wading through molasses, and the CPU fan spins up like a jet engine.

The bottleneck isn’t React; it’s the DOM. The Document Object Model simply wasn’t designed to handle massive element counts efficiently.

Enter List Virtualization (or Windowing). It’s not a new concept, but the tools have evolved. Today, we aren’t just looking for performance; we want flexibility, headless architecture, and TypeScript safety. That is where TanStack Virtual (formerly React Virtual) shines.

In this guide, we are going to bypass the basic “Hello World” and build a production-ready virtualized list capable of handling dynamic heights and smooth scrolling, ensuring your UI stays buttery smooth at 60fps.

The Concept: Why “Headless” Matters
#

Before writing code, let’s clarify the architectural shift. Older libraries like react-window were component-based. You imported a <FixedSizeList>, passed it props, and it rendered a div soup for you. It worked, but styling was a nightmare.

TanStack Virtual is headless. It doesn’t render markup. It gives you logic—offsets, indexes, and measurements—via a hook. You own the render. You own the CSS. This decoupling is why modern architectural patterns favor TanStack libraries.

How Virtualization Works
#

The concept is deceptively simple: only render what the user can see.

graph TD A[Total Dataset: 10,000 items] --> B{Virtualizer Logic} B -- User Scroll Position --> C[Calculate Visible Range] C --> D[Identify Buffer/Overscan] D --> E[Render only Items 400-420] E --> F[DOM: 20 Nodes + Padding] style F stroke:#f66,stroke-width:2px,stroke-dasharray: 5 5 style B fill:#282c34,color:#fff

Instead of 10,000 divs, we render a container div with a massive height (simulating the total scroll length) and absolute-position a handful of real elements within the visible viewport.

Prerequisites and Setup
#

To follow along, you should have a modern React environment set up. We are assuming TypeScript because, frankly, maintaining large lists without types is a hazardous hobby.

Environment:

  • React 18 or 19
  • TypeScript 5.x
  • Vite (recommended)

First, install the package. We are using the Beta/v3 branch which is the standard for modern development in 2025.

npm install @tanstack/react-virtual
# or
yarn add @tanstack/react-virtual

Step 1: The Basic Fixed-Height List
#

Let’s start with the most performant scenario: every row has the exact same height. This allows the virtualizer to calculate positions using simple math ($Index * Height$) without measuring DOM nodes.

We need some fake data. I usually create a utility for this to avoid cluttering the component.

// data.ts
export type Person = {
  id: number;
  name: string;
  role: string;
  bio: string;
};

export const generatePeople = (count: number): Person[] => {
  return Array.from({ length: count }, (_, i) => ({
    id: i,
    name: `User ${i}`,
    role: i % 3 === 0 ? 'Admin' : 'Developer',
    bio: `This is a bio for user ${i}. It creates some content context.`
  }));
};

Now, the component. Pay attention to the CSS structure—it’s the place where 90% of developers get stuck.

import React, { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { generatePeople } from './data';

const people = generatePeople(10000); // 10k rows

export const FixedList = () => {
  // 1. The scrollable container ref
  const parentRef = useRef<HTMLDivElement>(null);

  // 2. The Virtualizer Instance
  const rowVirtualizer = useVirtualizer({
    count: people.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50, // Pixel height of each row
    overscan: 5, // Render 5 extra items off-screen for smoothness
  });

  return (
    <div
      ref={parentRef}
      style={{
        height: `400px`, // The visible window height
        overflow: 'auto', // MUST be scrollable
        border: '1px solid #ccc',
        borderRadius: '8px'
      }}
    >
      {/* The "Track" - total estimated height */}
      <div
        style={{
          height: `${rowVirtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {/* The "Items" - absolutely positioned */}
        {rowVirtualizer.getVirtualItems().map((virtualItem) => {
          const person = people[virtualItem.index];
          
          return (
            <div
              key={virtualItem.key}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: `${virtualItem.size}px`,
                transform: `translateY(${virtualItem.start}px)`,
              }}
              className="flex items-center px-4 hover:bg-gray-50 transition-colors"
            >
              <span className="font-bold w-16">#{person.id}</span>
              <span className="flex-1">{person.name}</span>
              <span className="text-sm text-gray-500">{person.role}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
};

Critical Analysis
#

Notice the transform: translateY(...). This is more performant than using top because transforms don’t trigger layout recalculations (reflow), only compositing. We are manually positioning the row based on the virtualItem.start value calculated by the library.

Step 2: Handling Dynamic Heights
#

The real world is rarely fixed-height. Bios vary in length, comments have different word counts, and cards have images.

In older libraries, dynamic height was a configuration nightmare. In TanStack Virtual, it uses a ResizeObserver pattern. The library measures the DOM element after it renders and adjusts the internal calculations on the fly.

Here is the code adjustment. We remove the hard constraint on height and let the virtualizer measure the element.

import React, { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { generatePeople } from './data';

// Generate varied length content
const people = generatePeople(1000); 

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

  const rowVirtualizer = useVirtualizer({
    count: people.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100, // Best guess initial size
    overscan: 5, 
  });

  return (
    <div
      ref={parentRef}
      className="h-[600px] overflow-auto border rounded-lg shadow-sm bg-white"
    >
      <div
        className="relative w-full"
        style={{
          height: `${rowVirtualizer.getTotalSize()}px`,
        }}
      >
        {rowVirtualizer.getVirtualItems().map((virtualItem) => {
          const person = people[virtualItem.index];
          
          return (
            <div
              key={virtualItem.key}
              data-index={virtualItem.index} 
              ref={rowVirtualizer.measureElement} // <--- MAGIC HAPPENS HERE
              className="absolute top-0 left-0 w-full p-4 border-b border-gray-100"
              style={{
                transform: `translateY(${virtualItem.start}px)`,
              }}
            >
              <div className="flex justify-between mb-2">
                 <h3 className="font-bold">{person.name}</h3>
                 <span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
                   {person.role}
                 </span>
              </div>
              {/* Randomly long text to force dynamic height */}
              <p className="text-gray-600 text-sm leading-relaxed">
                {person.bio.repeat(Math.ceil(Math.random() * 5))}
              </p>
            </div>
          );
        })}
      </div>
    </div>
  );
};

The measureElement Ref
#

The key line is ref={rowVirtualizer.measureElement}. When this div mounts, TanStack Virtual attaches a ResizeObserver. If the content expands (e.g., an image loads or text wraps), the virtualizer updates the offset for every item below it. It fixes the scroll jitter that used to plague dynamic lists.

Library Comparison: Why TanStack?
#

You might be wondering if you should stick with react-window. Let’s look at the breakdown.

Feature TanStack Virtual (v3) React-Window Virtuoso
Architecture Headless (Hooks only) Component-based Component-based
Bundle Size ~4kb (Tiny) ~7kb ~13kb
Dynamic Heights Built-in (Automatic) Hard (Requires specific cache) Built-in
Framework Agnostic Yes (React, Vue, Solid, Svelte) No (React only) No (React only)
SSR Support Excellent Difficult Good
Styling 100% Control Opinionated Opinionated

The clear winner for modern applications requiring custom design systems is TanStack. You aren’t fighting the library’s divs; you are just using its math.

Common Pitfalls and Performance Killers
#

Even with virtualization, you can shoot yourself in the foot. Here are the “gotchas” I see in code reviews daily.

1. The “Estimator” Trap
#

estimateSize is not just a placeholder. If your estimate is wildly off (e.g., you estimate 50px but items are usually 500px), the scrollbar will “jump” or resize violently as the user scrolls and the real items are measured. Try to get the average height as close as possible.

2. Complex Item Renderers
#

Virtualization reduces DOM nodes, but it doesn’t make your render function free. If your list item component creates 50 sub-components or performs heavy calculations (filtering/sorting) inside the render loop, you will still drop frames during rapid scrolling.

Solution: Memoize your row component.

const Row = React.memo(({ data, index }) => {
  // Heavy logic
  return <div>...</div>
});

3. Layout Thrashing
#

If you pass inline styles that change excessively or force the browser to recalculate layout (like reading offsetHeight manually inside the loop), performance tanks. Let the Virtualizer handle the measurements.

Advanced Pattern: Sticky Headers
#

A common requirement is sticky headers (like an address book with “A”, “B”, “C” headers). TanStack Virtual supports this natively by calculating the range.

While full implementation is beyond this article’s scope, the logic involves checking the virtualItem.index against your data’s group structure and modifying the transform style to utilize position: sticky. However, because we are using absolute positioning for virtualization, standard CSS sticky doesn’t work out of the box. You often have to emulate stickiness by calculating the translateY dynamically based on the parent’s scroll top.

Pro Tip: For simple sticky headers, it’s often easier to render the header outside the virtual list and sync it, or use the rangeExtractor feature in TanStack Virtual to keep specific indices (the headers) in the DOM.

Conclusion
#

Virtualization is one of those techniques that separates junior apps from senior architecture. Users in 2025 have zero tolerance for lag. Whether you are building a data grid, a chat log, or an infinite social feed, react-virtual provides the mathematical backbone without imposing a specific DOM structure.

Key Takeaways:

  1. Use Fixed Height whenever possible for maximum performance.
  2. Use Dynamic Height with measureElement when content varies.
  3. Always ensure your parent container has a defined height and overflow: auto.
  4. Keep your row components lightweight and memoized.

Start by refactoring your largest list today. Your users’ CPU fans will thank you.

Further Reading
#