Web Development
ReactPerformanceTanStack VirtualVirtualizationInfinite Scroll

React Virtualization Guide: Windowing Large Lists and Grids with TanStack Virtual (and When Not To)

AO
Adrijan Omićević
·16 min read

# What You’ll Build (and Why Virtualization Works)#

React list performance often collapses for one simple reason: the DOM gets too big. Rendering thousands of nodes increases layout, style recalculation, memory, and React reconciliation work. Virtualization fixes that by only rendering what is visible in the scroll viewport plus a small buffer.

In practice, virtualization commonly cuts DOM nodes by 90 to 99 percent. For example, a list of 10,000 rows rendered normally can easily create 10,000 to 50,000 DOM nodes depending on row complexity. A virtualized list might keep only 30 to 120 rows mounted at any time, which is a reduction from 10,000 down to around 60 rendered rows for typical viewports.

This guide shows how to implement:

  • High-performance list and grid virtualization with TanStack Virtual
  • Infinite loading that does not stutter during fast scroll
  • Sticky headers that remain aligned while content is windowed
  • Profiling workflows to prove measurable gains
  • A production checklist for edge cases: dynamic heights, resizing, and accessibility

For deeper React performance patterns that complement virtualization, see React performance profiling, memoization, rendering patterns and for broader site-level performance considerations, read website performance optimization.

# When Virtualization Helps (and When It Hurts)#

Virtualization is most valuable when the bottleneck is DOM and render cost. It is less valuable when your bottleneck is network, expensive images, or heavy CPU work per item that still happens for every visible row.

Use virtualization when#

ScenarioTypical symptomsExpected impact
1,000+ rows list or log viewerScroll jank, input lag, long commits in React ProfilerMajor DOM reduction, smoother scroll
Data table with many rows and columnsSlow initial render, heavy memory usageBig improvement, especially with column virtualization
Grid of cards (thumbnails, products)CPU spikes during scrollReduced layout and repaint work
Infinite feed with pagingStutters when appending new pagesStable scrolling with buffered overscan

Avoid or delay virtualization when#

ScenarioWhy it can be a bad fitBetter approach
Less than ~200 simple itemsComplexity exceeds gainsKeep it simple, optimize renders
Rows need native browser search and selection across entire pageOnly visible rows are in DOMProvide dedicated search UI or server-side search
Complex focus management with many interactive controls per rowUnmounted elements lose focus stateConsider pagination or redesign row interactions
SEO requires all content in DOMVirtualization hides contentSSR a summary, paginate, or render fewer items

🎯 Key Takeaway: Virtualization is a UI performance tool, not a data fetching tool. Use it to reduce DOM and render work, then pair it with paging or infinite loading for data scale.

# Prerequisites and Setup#

RequirementVersionNotes
React18+Works with concurrent rendering
TanStack Virtual3+@tanstack/react-virtual
Browser APIsResizeObserver recommendedHelps dynamic measurement

Install:

Bash
npm i @tanstack/react-virtual

You will implement a “scroll element” container, then let the virtualizer calculate which items should be rendered and where.

# Step 1: Virtualize a Fixed-Height List (fastest baseline)#

Start with fixed heights whenever possible. Fixed height gives the most stable performance because no runtime measurement is needed.

Minimal fixed-size list example#

TSX
import React, { useMemo, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
 
export function VirtualizedList() {
  const parentRef = useRef<HTMLDivElement | null>(null)
 
  const items = useMemo(
    () => Array.from({ length: 10000 }, (_, i) => `Row ${i}`),
    []
  )
 
  const rowVirtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
    overscan: 8,
  })
 
  return (
    <div
      ref={parentRef}
      style={{ height: 500, overflow: 'auto', border: '1px solid #ddd' }}
    >
      <div
        style={{
          height: rowVirtualizer.getTotalSize(),
          position: 'relative',
        }}
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: virtualRow.size,
              transform: `translateY(${virtualRow.start}px)`,
              display: 'flex',
              alignItems: 'center',
              padding: '0 12px',
              boxSizing: 'border-box',
              borderBottom: '1px solid #f0f0f0',
            }}
          >
            {items[virtualRow.index]}
          </div>
        ))}
      </div>
    </div>
  )
}

Why this matters:

  • getTotalSize() creates the correct scroll height without rendering all rows.
  • Absolute positioning prevents the browser from laying out thousands of siblings.
  • overscan avoids blank gaps during fast scroll by rendering a buffer.

Tuning overscan with real numbers#

Overscan is a tradeoff: larger overscan increases CPU and memory but reduces the chance of blanking during fast scroll. On modern devices, 8 to 20 is a good starting range for lists.

Content typeRecommended overscanRationale
Simple text rows6 to 12Fast to render, low blanking risk
Complex rows (avatars, menus)12 to 24Avoid visible mount pop-in
Heavy media (images, charts)4 to 10Too much overscan can spike memory

💡 Tip: If users “fling scroll” on trackpads, increase overscan before you start rewriting row components. It is often the cheapest fix.

# Step 2: Virtualize a Grid (cards, galleries, dashboards)#

Grids need a consistent positioning strategy. The easiest approach is to virtualize rows, where each “row” contains multiple columns. That keeps the virtualizer one-dimensional and usually performs well.

Grid virtualization by row batching#

TSX
import React, { useMemo, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
 
export function VirtualizedGrid() {
  const parentRef = useRef<HTMLDivElement | null>(null)
 
  const colCount = 4
  const cardHeight = 180
 
  const cards = useMemo(
    () => Array.from({ length: 5000 }, (_, i) => ({ id: i, title: `Card ${i}` })),
    []
  )
 
  const rowCount = Math.ceil(cards.length / colCount)
 
  const rowVirtualizer = useVirtualizer({
    count: rowCount,
    getScrollElement: () => parentRef.current,
    estimateSize: () => cardHeight,
    overscan: 6,
  })
 
  return (
    <div ref={parentRef} style={{ height: 700, overflow: 'auto' }}>
      <div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
        {rowVirtualizer.getVirtualItems().map((vRow) => {
          const startIndex = vRow.index * colCount
          const rowItems = cards.slice(startIndex, startIndex + colCount)
 
          return (
            <div
              key={vRow.key}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: vRow.size,
                transform: `translateY(${vRow.start}px)`,
                display: 'grid',
                gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
                gap: 12,
                padding: 12,
                boxSizing: 'border-box',
              }}
            >
              {rowItems.map((c) => (
                <div
                  key={c.id}
                  style={{
                    height: cardHeight - 24,
                    border: '1px solid #e5e5e5',
                    borderRadius: 10,
                    padding: 12,
                    boxSizing: 'border-box',
                    background: 'white',
                  }}
                >
                  <strong>{c.title}</strong>
                </div>
              ))}
            </div>
          )
        })}
      </div>
    </div>
  )
}

If you need true two-dimensional virtualization (many rows and many columns), TanStack Virtual can do it with two virtualizers, one for rows and one for columns. That is especially relevant for spreadsheets and wide tables, and it pairs well with TanStack Table. For that full scenario, see React table virtualization and infinite scroll with TanStack Table.

# Step 3: Add Infinite Loading Without Scroll Glitches#

A virtualized list can still stutter when you append new pages if you block the main thread or trigger large rerenders. The goal is to fetch earlier, append efficiently, and keep stable item keys.

Pattern: sentinel row plus prefetch threshold#

  • Reserve one extra “loading” row at the end.
  • When the user scrolls close to the end, fetch the next page.
  • Keep rendering stable with overscan and a prefetch threshold.
TSX
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
 
type Row = { id: string; label: string }
 
export function VirtualizedInfiniteList() {
  const parentRef = useRef<HTMLDivElement | null>(null)
  const [rows, setRows] = useState<Row[]>(() =>
    Array.from({ length: 200 }, (_, i) => ({ id: String(i), label: `Row ${i}` }))
  )
  const [isLoading, setIsLoading] = useState(false)
  const hasMore = rows.length < 5000
 
  const count = hasMore ? rows.length + 1 : rows.length
 
  const v = useVirtualizer({
    count,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 44,
    overscan: 12,
  })
 
  const virtualItems = v.getVirtualItems()
  const lastItem = virtualItems[virtualItems.length - 1]
 
  useEffect(() => {
    if (!lastItem) return
    const isAtEnd = lastItem.index >= rows.length - 1
    if (!isAtEnd || isLoading || !hasMore) return
 
    setIsLoading(true)
    setTimeout(() => {
      setRows((prev) => {
        const next = prev.length
        const more = Array.from({ length: 200 }, (_, i) => ({
          id: String(next + i),
          label: `Row ${next + i}`,
        }))
        return prev.concat(more)
      })
      setIsLoading(false)
    }, 400)
  }, [lastItem, rows.length, isLoading, hasMore])
 
  const totalSize = useMemo(() => v.getTotalSize(), [v, rows.length, hasMore])
 
  return (
    <div ref={parentRef} style={{ height: 520, overflow: 'auto', border: '1px solid #ddd' }}>
      <div style={{ height: totalSize, position: 'relative' }}>
        {virtualItems.map((item) => {
          const isLoader = hasMore && item.index === rows.length
          const label = isLoader ? (isLoading ? 'Loading…' : 'Load more…') : rows[item.index].label
 
          return (
            <div
              key={isLoader ? 'loader' : rows[item.index].id}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: item.size,
                transform: `translateY(${item.start}px)`,
                padding: '0 12px',
                boxSizing: 'border-box',
                display: 'flex',
                alignItems: 'center',
                borderBottom: '1px solid #f0f0f0',
                color: isLoader ? '#666' : '#111',
              }}
            >
              {label}
            </div>
          )
        })}
      </div>
    </div>
  )
}

What to measure:

  • Time to append a page should stay roughly constant as total rows grow.
  • Scrolling should not show blank gaps when data arrives.
  • React commits should remain small during typical scroll interactions.

⚠️ Warning: Do not use array index as the React key for real data. When you insert, filter, or refresh pages, index keys cause remounts and state loss, which is especially visible in virtualized UIs.

# Step 4: Sticky Headers That Stay Aligned#

Sticky headers are common in tables, grids, and grouped lists. With virtualization, you typically want:

  • A header that is not virtualized (always in DOM)
  • A scroll container where only the body is virtualized
  • Optional: sticky group headers inside the list

Sticky table header pattern#

Use a wrapper with a fixed header and a scrollable body. The header does not move, the body is virtualized.

LayerPositioningVirtualized
Header rowsticky or separate containerNo
Scroll containeroverflow autoN/A
Rowsabsolute positioned inside spacerYes

Implementation approach:

  1. 1
    Render header outside the virtualized body.
  2. 2
    Ensure header column widths match body columns.
  3. 3
    If using CSS grid or fixed widths, keep them in a single shared config object.

If you need a full table solution with sorting, pinning, and virtualization, start from React table virtualization and infinite scroll with TanStack Table.

ℹ️ Note: Sticky elements inside a transformed parent can behave unexpectedly. If you use transform on ancestor elements of a sticky header, the browser may treat the sticky container differently. Keep sticky headers outside transformed containers, or use a separate header wrapper.

# Step 5: Dynamic Row Heights (the hard part)#

Real lists rarely have perfect fixed heights. Comments expand, badges wrap, error states appear, and fonts load late. TanStack Virtual supports measurement, but you must treat it as a system that needs stable constraints.

Best practices for dynamic heights#

ProblemSymptomFix
Height changes after renderOverlaps, jumpy scrollMeasure element, re-measure on content change
Responsive layoutWrong estimates after resizeCall virtualizer.measure() on resize
Late-loaded fonts or imagesLayout shift while scrollingSet explicit image sizes, reserve space, re-measure after load
Mixed heightsPoor scroll accuracyUse reasonable estimateSize and measurement

Practical approach:

  1. 1
    Keep a good estimateSize (close to median row height).
  2. 2
    Measure actual row elements.
  3. 3
    Avoid expensive layout inside each row.

If your row content changes after an async update, re-measure. Do it intentionally, not on every render.

TSX
import React, { useEffect, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
 
export function VirtualizedDynamicRows() {
  const parentRef = useRef<HTMLDivElement | null>(null)
  const [rows] = useState(() =>
    Array.from({ length: 3000 }, (_, i) => ({
      id: String(i),
      text: i % 7 === 0 ? 'A longer row that will likely wrap on smaller widths.' : 'Short row.',
    }))
  )
 
  const v = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 52,
    overscan: 10,
  })
 
  useEffect(() => {
    const onResize = () => v.measure()
    window.addEventListener('resize', onResize)
    return () => window.removeEventListener('resize', onResize)
  }, [v])
 
  return (
    <div ref={parentRef} style={{ height: 560, overflow: 'auto', border: '1px solid #ddd' }}>
      <div style={{ height: v.getTotalSize(), position: 'relative' }}>
        {v.getVirtualItems().map((item) => (
          <div
            key={rows[item.index].id}
            ref={v.measureElement}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${item.start}px)`,
              padding: 12,
              boxSizing: 'border-box',
              borderBottom: '1px solid #f0f0f0',
              lineHeight: 1.3,
            }}
          >
            <strong>#{rows[item.index].id}</strong> — {rows[item.index].text}
          </div>
        ))}
      </div>
    </div>
  )
}

This pattern trades some CPU for correctness. The fewer dynamic changes you have, the better it will feel.

# Profiling: Prove the Performance Gain (not just “feels faster”)#

Virtualization should be measurable. Your goal is to reduce:

  • Mounted DOM nodes
  • React commit duration during scroll
  • Long tasks on the main thread

What to capture before and after#

MetricToolTarget
DOM node countChrome DevTools, Elements panelReduce by 90 percent or more for big lists
React commit timeReact DevTools ProfilerSmaller commits during scroll, fewer rerenders
Long tasksChrome Performance panelFewer tasks over 50 ms during scroll
MemoryChrome Performance or Task ManagerLower heap usage, fewer detached nodes

Recommended workflow:

  1. 1
    Record a baseline with a non-virtualized list of the same row component.
  2. 2
    Record the virtualized version with the same dataset size.
  3. 3
    Scroll at a consistent speed and compare React commits and long tasks.

For React-specific profiling patterns and how to interpret commit graphs, use React performance profiling, memoization, rendering patterns.

Practical profiling tips#

  • Profile on a throttled CPU mode (for example 4x slowdown) to surface issues that real users on mid-range devices hit.
  • Measure “scroll while new page loads” for infinite lists, not just idle scroll.
  • Watch for avoidable rerenders: row components should not rerender when unrelated state updates.

If you see commits triggered by scrolling, check that you are not storing scroll position in React state. Let the browser handle scroll, and let the virtualizer derive what is visible.

# Accessibility and UX Checklist (virtualization-specific)#

Virtualization changes the DOM while the user scrolls. That affects keyboard navigation, screen readers, and expectations like “find in page.”

Keyboard and focus#

  1. 1
    Ensure focus does not land on items that will unmount immediately.
  2. 2
    If rows contain inputs, preserve state outside the row component (store by id).
  3. 3
    Provide clear focus styles and ensure tab order remains predictable.

Screen readers and semantics#

  • Prefer semantic containers where possible, but do not force table semantics if you cannot keep correct relationships.
  • Provide aria-label on the scroll region so it is discoverable.
  • Announce loading states for infinite lists with an aria-live region if loading is automatic.

“Find in page” and selection#

Browser find only sees mounted DOM. For data-heavy apps, provide an explicit search UI and filter the dataset, rather than relying on native find.

⚠️ Warning: Virtualized tables that pretend to be real HTML tables often break assistive tech expectations. If you need true table semantics for accessibility compliance, consider pagination or a hybrid approach where you limit rows but keep a real table structure.

# Edge Cases Checklist (production readiness)#

Use this as a pre-launch checklist for any “React virtualization TanStack Virtual” implementation.

Edge caseRiskMitigation
Dynamic row heightsOverlaps and scroll jumpsUse measureElement, good estimateSize, re-measure on resize
Images loading lateLayout shiftSet width and height, reserve space, re-measure after load if needed
Filtering and sortingJump to wrong scroll offsetReset scroll to top, keep stable keys, avoid index keys
Appending pagesJanky scroll during fetchPrefetch earlier, keep overscan, render loader row
Variable container sizeIncorrect measurementsCall measure() on resize and layout changes
Sticky header alignmentMisaligned columnsShared column width config, header outside transformed container
Server renderingHydration mismatchRender a limited initial window, avoid reading scroll position on first paint
Touch devicesBlank gaps during fast flingIncrease overscan and prefetch threshold

If you are optimizing the entire page rather than just the list, coordinate virtualization with broader improvements like image optimization, caching, and bundle size reduction. Use website performance optimization to avoid fixing one hotspot while ignoring bigger bottlenecks.

# Key Takeaways#

  • Start with fixed-size virtualization first, because it is the fastest and simplest baseline to ship.
  • Tune overscan based on row complexity and input devices, then validate with React Profiler and Chrome Performance.
  • Implement infinite loading using a loader row and a prefetch threshold so scroll never hits an empty gap.
  • Treat dynamic row heights as a first-class edge case: measure elements, re-measure on resize, and reserve space for late-loading content.
  • Plan accessibility explicitly: manage focus, provide search UI instead of relying on browser find, and avoid fake table semantics if you need compliance.

# Conclusion#

TanStack Virtual is one of the most practical ways to make large React lists and grids feel instant: fewer DOM nodes, smaller React commits, and smoother scroll under real-world loads. The real win comes when you combine windowing, infinite loading, and disciplined profiling so you can prove the improvement instead of guessing.

If you want us to review your current list or table implementation, profile it, and ship a production-ready virtualization setup with sticky headers and infinite loading, contact Samioda via our React and Next.js services page and include a screen recording plus your current dataset size and row complexity.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.