Virtualize long lists

Render only visible rows plus a small overscan, placed with translateY.

From frontend-patterns, section “Virtualization for Long Lists”.

Same list, two DOM sizes

Press Mount, then Restyle and Scroll test.

Every row mountedBad

Not mounted

Rows in DOM
0
Elements
0
Mount to paint
–
Restyle
–
Slowest scroll frame
–
Virtual, overscan 5Good

Not mounted

Rows in DOM
0
Elements
0
Mount to paint
–
Restyle
–
Slowest scroll frame
–

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

export function VirtualMarketList({ markets }: { markets: Market[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: markets.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,  // Estimated row height
    overscan: 5  // Extra items to render
  })

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

Overscan hides the blank edge

Press Fling down, then try overscan 4 and 20.

overscan: 0Bad
Rows in DOM
0
Largest blank
–
Frames with a gap
–
overscan: 5Good
Rows in DOM
0
Largest blank
–
Frames with a gap
–

useVirtualizer({
  count: markets.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 56,
  overscan: 5, // rows kept mounted past each edge: 5 × 56 = 280px of runway
})

The estimate is the scrollbar

Move the estimate off the real height, then Jump to end.

Slot height = estimateBad
Spacer height
–
Each card vs slot
–
Measured rowsGood
Spacer height
–
Rows measured
–
Thumb jumps
–

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

{virtualizer.getVirtualItems().map((virtualRow) => (
  <div
    key={virtualRow.key}
    data-index={virtualRow.index}
    ref={virtualizer.measureElement} // no fixed height: the row reports its own
    style={{
      position: 'absolute', top: 0, left: 0, width: '100%',
      transform: `translateY(${virtualRow.start}px)`,
    }}
  >
    <MarketCard market={markets[virtualRow.index]} />
  </div>
))}

Why it matters, pros and cons

Why it matters

Each mounted row costs memory, layout and paint. Virtualize to pay for the view.

Pros

  • DOM size stays flat as data grows.
  • Mount time ignores data size.
  • Restyles touch dozens of rows, not thousands.

Cons

  • Find in page misses unmounted rows.
  • Variable heights need measuring; bad estimates jump.
  • Under a few hundred rows, paginate instead.