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.
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-virtualStep 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:
- Use Fixed Height whenever possible for maximum performance.
- Use Dynamic Height with
measureElementwhen content varies. - Always ensure your parent container has a defined height and
overflow: auto. - Keep your row components lightweight and memoized.
Start by refactoring your largest list today. Your users’ CPU fans will thank you.