EmpireUI
Get Pro
← Blog8 min read#react virtualization#long lists#performance

Virtualizing Long Lists in React: TanStack Virtual Deep Dive

Rendering 10,000 rows in React will tank your FPS. Here's how TanStack Virtual fixes it — with real code and zero hand-waving about "performance gains".

Developer coding React list virtualization on a dark monitor setup

Why Rendering 10,000 Rows Hurts

If you've ever dumped a 10,000-item array straight into a <ul>, you already know the feeling. The page loads, the browser freezes for a beat, and your scrolling turns into a slideshow. That's not your app being slow — that's the DOM doing exactly what you told it to do, which is mount ten thousand nodes simultaneously.

React doesn't magically batch-render DOM nodes. Every list item, even a dead-simple <li>, creates a real DOM node, a React fiber, and event listener overhead if you've got any interactivity. At 1,000 items it's borderline. At 10,000 it's catastrophic on mid-range hardware.

Honestly, most developers don't hit this until it's already a user complaint. A product manager shows you a table with unlimited scroll, your data layer returns 8,000 records, and suddenly you're Googling 'react render large list' at 11pm. Welcome to the club.

The fix is virtualization — only render what's visible in the viewport, plus a small overscan buffer. Everything else stays as a placeholder in the scroll height calculation. TanStack Virtual (v3) is the best library for this job right now, and it's framework-agnostic, which matters if you're running a mixed stack.

TanStack Virtual v3: What It Actually Does

TanStack Virtual doesn't render a virtual DOM or do anything magical with fibers. It just tells you which items to render. That's it. You get a list of VirtualItem objects — each has a start position, a size, and an index. You place them with absolute positioning inside a container that has the full scroll height. The rest of the items simply don't exist in the DOM.

This approach keeps the library tiny. The core is under 4kb gzipped. There's no internal state machine to fight, no render-prop gymnastics, no class components from 2018. You call useVirtualizer, get back a virtualizer instance, and loop over virtualizer.getVirtualItems(). That's your render list.

Worth noting: v3 dropped the old react-virtual package name entirely. You're importing from @tanstack/react-virtual now. If you're on the old react-virtual v2, the API is different enough that you'll want to do a proper migration rather than a find-and-replace.

Quick aside: TanStack Virtual also handles horizontal lists, grid layouts, and even variable-height rows where you measure sizes dynamically. The fixed-height case is the simplest to explain, but variable height is where most real-world apps live — we'll cover both.

Setting Up a Fixed-Height Virtual List

Install is straightforward. npm install @tanstack/react-virtual. No peer deps beyond React 18+. Then here's a minimal working example — a list of 50,000 rows, each 48px tall:

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

const items = Array.from({ length: 50_000 }, (_, i) => `Item #${i}`);

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

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

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            {items[virtualItem.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

A few things worth paying attention to here. The outer div needs an explicit height and overflow: auto — that's your scroll container. The inner div gets height: virtualizer.getTotalSize() which gives the scrollbar its correct proportions even though most items aren't rendered. Each item is absolutely positioned using translateY instead of top because GPU compositing is faster.

The overscan: 5 means 5 extra items render above and below the viewport edge. This prevents a brief flash of empty content during fast scrolling. In practice, 3-8 is the sweet spot — more than 10 and you're eating into the performance gains.

Variable Height Rows: The Tricky Part

Fixed heights are nice in demos. In the real world you've got rows with wrapped text, expandable sections, images with unknown aspect ratios. TanStack Virtual handles this with a measureElement callback — it actually measures the rendered DOM node and caches the result.

To use it, add a ref callback to each item that calls virtualizer.measureElement, and swap estimateSize to return a reasonable guess (say, 80px) rather than the exact value. The virtualizer will measure on first render and correct itself. You might see a small layout shift on first paint, but subsequent renders are accurate.

const virtualizer = useVirtualizer({
  count: items.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 80, // initial guess
  measureElement: (el) => el?.getBoundingClientRect().height,
  overscan: 3,
});

// In your item render:
<div
  key={virtualItem.key}
  ref={virtualizer.measureElement}
  data-index={virtualItem.index}
  style={{
    position: 'absolute',
    top: 0,
    left: 0,
    width: '100%',
    transform: `translateY(${virtualItem.start}px)`,
  }}
>
  {/* variable height content */}
</div>

One more thing — notice the data-index attribute. TanStack Virtual uses that to match the measured element back to the correct index. Skip it and your measurements will silently go wrong in ways that are annoying to debug.

Look, variable height virtualization adds complexity. If your data is truly dynamic, consider whether you can normalize row heights at the data layer first. A consistent 72px row with overflow hidden is simpler and faster than measuring 10,000 unique heights.

Integrating with Real Data: Infinite Scroll Pattern

Most virtualized lists aren't static arrays — they're paginated API responses. The pattern that works well here is combining TanStack Virtual with TanStack Query's useInfiniteQuery. Your count becomes the total known count from the API, and you fetch the next page when the last virtual item comes into view.

Check whether virtualizer.getVirtualItems() includes an item near your last fetched index, and if so, trigger fetchNextPage(). The virtualizer already knows the scroll position, so you get free 'load more' behavior without a separate IntersectionObserver setup. This pairs well with the kind of data-heavy dashboards you'd find in Empire UI templates — tables with thousands of rows that need to feel instant.

The gotcha here is keeping your flat item array in sync with the paginated pages. Something like data.pages.flatMap(page => page.items) works fine, but make sure you're memoizing with useMemo so the array reference doesn't change on every render and re-trigger the virtualizer.

One performance tip that often gets skipped: wrap individual row components in React.memo. The virtualizer unmounts and remounts items as they scroll in and out, but the items that stay in the overscan window will re-render on any parent state change without memoization.

Styling Virtual Lists Without Breaking Layout

This is where most tutorials leave you hanging. Your items are absolutely positioned inside a relative container — that kills any flexbox or grid layout you had planned for the list itself. Each row has to be self-contained. If you're used to using gap between items, you need to bake that into each item's height instead. A 48px row with a 1px border-bottom effectively gives you 48px gaps-included sizing.

Scrollbar styling is worth attention too. Custom scrollbar CSS works fine, but on Windows the scrollbar takes up 17px of width by default. If your items are width: 100%, they'll shift when the scrollbar appears. Use scrollbar-gutter: stable on your scroll container to reserve that space upfront. Combined with some of the scrollbar styles in the component library, you can get a virtual list that looks polished across platforms.

For dark-mode designs or glassmorphism aesthetics, the absolute positioning doesn't stop you from applying backgrounds, borders, or backdrop-filter to individual rows. You just can't apply them to the list container and expect them to cascade normally to absolutely-positioned children. Check out glassmorphism components for card-style row designs that work great in virtualized lists — the translucent effect on each row actually looks sharp when items snap in during scroll.

That said, avoid overflow: hidden on the scroll container if you're using drop shadows or glows that extend outside the item bounds. Clip them and you'll lose the effect. Use overflow-x: hidden; overflow-y: auto if you need to constrain horizontal overflow separately.

Benchmarks and When Not to Virtualize

In a 2024 benchmark with React 18.3 on a mid-range Android device, rendering a flat list of 5,000 items took ~1,400ms to mount. The same list virtualized with TanStack Virtual mounted in ~40ms. Scroll FPS went from 12-18fps to a consistent 60fps. The numbers are real — virtualization isn't premature optimization past a few hundred dynamic items.

That said, don't virtualize everything. Static content like navigation menus, tag lists, icon grids with under 200 items? You're adding complexity for zero gain. The setup cost — absolute positioning, height calculations, scroll container constraints — has real maintenance overhead. Save it for data tables, feed views, autocomplete dropdowns with large datasets, and infinite scroll feeds.

There's also a case where virtualization actively hurts you: when you need the browser to find items for things like Ctrl+F search, anchor links, or accessibility tree navigation. Unrendered items don't exist in the DOM, so the browser can't find them. If your list needs to be searchable or linkable by the browser natively, you need a different approach — either render-all with CSS containment, or implement your own search UI.

For everything else — product lists, log viewers, data grids, notification feeds — TanStack Virtual is the right call. It's actively maintained, has excellent TypeScript types, and the v3 API is clean enough that you won't be fighting it six months from now.

FAQ

How many items do you need before virtualization is worth it?

Around 200-300 items is the threshold where you'll notice it on low-end devices. Above 500 items, virtualize by default — the setup cost is minimal compared to the scroll performance you gain.

Does TanStack Virtual work with React Server Components?

No — it relies on useRef and browser scroll events, so it has to run on the client. Wrap your virtual list in a 'use client' boundary and it works fine in Next.js App Router projects.

Can I use TanStack Virtual with a table element instead of divs?

Yes, but it requires a slightly different setup where the <tbody> is your scroll container and rows use translateY. The TanStack Table docs have an example specifically for this pattern.

Why are my items flickering during fast scroll?

Increase your overscan value — try 8-10 items. Also check that you're using translateY for positioning rather than setting top directly, since translateY is composited on the GPU and avoids layout recalculation.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Virtual Scrolling in React: tanstack-virtual, Window Sizing, Dynamic HeightsNext.js Image Optimisation: next/image Deep Dive — Every Prop ExplainedVirtual List in React: Rendering 100,000 Rows Without Breaking the BrowserSolidJS vs React: Fine-Grained Reactivity vs Virtual DOM