React Infinite Scroll and Virtualization with react-window (2026)

Rendering 10,000 list items crashes browsers. Virtualization solves this by rendering only the items visible in the viewport — a 10,000-item list renders just 20–30 DOM nodes at any time. Combined with infinite scroll to load data on demand, you can handle arbitrarily large datasets with no performance penalty.

Why Virtualize

Rendering 5,000 list rows creates 5,000 DOM nodes. Each node consumes memory and contributes to layout calculations. On a mid-range device, scrolling becomes janky above ~500 items. Virtualization limits rendered nodes to the visible window plus a small overscan buffer — typically 20–40 nodes regardless of total list length.

react-window: FixedSizeList

npm install react-window
import { FixedSizeList as List } from 'react-window'

const ROW_HEIGHT = 60

// Row component — MUST be defined outside the parent component
// to avoid recreation on every render
const Row = ({ index, style, data }) => {
  const item = data[index]
  return (
    // style prop is mandatory — sets position, height, width
    <div style={style} className="flex items-center gap-4 px-4 border-b">
      <img src={item.avatar} alt="" className="w-10 h-10 rounded-full" />
      <div>
        <p className="font-medium">{item.name}</p>
        <p className="text-sm text-gray-500">{item.email}</p>
      </div>
    </div>
  )
}

function UserList({ users }) {
  return (
    <List
      height={600}           // Viewport height (px)
      itemCount={users.length}
      itemSize={ROW_HEIGHT}  // Fixed row height
      width="100%"
      itemData={users}       // Passed to Row as data prop
      overscanCount={5}      // Extra items rendered above/below viewport
    >
      {Row}
    </List>
  )
}
Critical rule: Never define the Row component inside the parent component function. This recreates the function reference on every render, causing react-window to unmount/remount every visible row. Always define row renderers at module scope or memoize them.

VariableSizeList

When rows have different heights, use VariableSizeList with an itemSize function:

import { VariableSizeList as List } from 'react-window'
import { useRef, useCallback } from 'react'

// Pre-calculate heights or measure dynamically
const itemHeights = items.map(item =>
  item.type === 'header' ? 48 : item.expanded ? 120 : 60
)

function VariableList({ items }) {
  const listRef = useRef<List>(null)

  const getItemSize = useCallback(
    (index: number) => itemHeights[index],
    [itemHeights]
  )

  // Call this after dynamic height changes
  function resetHeightAtIndex(index: number) {
    listRef.current?.resetAfterIndex(index)
  }

  return (
    <List
      ref={listRef}
      height={600}
      itemCount={items.length}
      itemSize={getItemSize}
      width="100%"
      estimatedItemSize={60}   // Helps with scroll bar accuracy
    >
      {({ index, style }) => (
        <div style={style}>
          <ItemComponent item={items[index]} onExpand={() => resetHeightAtIndex(index)} />
        </div>
      )}
    </List>
  )
}

FixedSizeGrid

import { FixedSizeGrid as Grid } from 'react-window'

const COLUMN_COUNT = 3
const COLUMN_WIDTH = 300
const ROW_HEIGHT = 250

const Cell = ({ columnIndex, rowIndex, style, data }) => {
  const index = rowIndex * COLUMN_COUNT + columnIndex
  const item = data[index]
  if (!item) return <div style={style} />   // Empty cells at end

  return (
    <div style={{ ...style, padding: 8 }}>
      <div className="card h-full">
        <img src={item.image} alt={item.name} className="w-full h-40 object-cover rounded-t-lg" />
        <p className="p-3 font-medium">{item.name}</p>
      </div>
    </div>
  )
}

function ProductGrid({ products }) {
  const rowCount = Math.ceil(products.length / COLUMN_COUNT)

  return (
    <Grid
      columnCount={COLUMN_COUNT}
      columnWidth={COLUMN_WIDTH}
      height={600}
      rowCount={rowCount}
      rowHeight={ROW_HEIGHT}
      width={COLUMN_COUNT * COLUMN_WIDTH}
      itemData={products}
    >
      {Cell}
    </Grid>
  )
}

TanStack Virtual

TanStack Virtual is the modern alternative — headless, framework-agnostic, and handles dynamic sizes via a ResizeObserver:

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

function VirtualList({ items }) {
  const parentRef = useRef<HTMLDivElement>(null)

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

  return (
    // Scrollable container — must have a fixed height
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      {/* Total height spacer */}
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            data-index={virtualItem.index}
            ref={virtualizer.measureElement}   // Auto-measures actual height
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <ListRow item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

Infinite Scroll with IntersectionObserver

import { useRef, useCallback } from 'react'

function useInfiniteScroll(onLoadMore: () => void, hasMore: boolean) {
  const observer = useRef<IntersectionObserver | null>(null)

  const sentinelRef = useCallback((node: HTMLDivElement | null) => {
    if (observer.current) observer.current.disconnect()
    if (!node || !hasMore) return

    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting) onLoadMore()
    }, { rootMargin: '200px' })   // Trigger 200px before hitting the bottom

    observer.current.observe(node)
  }, [onLoadMore, hasMore])

  return sentinelRef
}

// Usage with TanStack Query infinite query
function InfinitePostFeed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ['posts'],
      queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
      getNextPageParam: (lastPage) => lastPage.nextPage,
    })

  const sentinelRef = useInfiniteScroll(fetchNextPage, !!hasNextPage)

  const allPosts = data?.pages.flatMap(p => p.posts) ?? []

  return (
    <div>
      {allPosts.map(post => <PostCard key={post.id} post={post} />)}

      {/* Sentinel — triggers load when scrolled into view */}
      <div ref={sentinelRef} className="h-8" />

      {isFetchingNextPage && <Spinner />}
      {!hasNextPage && <p className="text-center text-gray-500">You've reached the end</p>}
    </div>
  )
}

TanStack Query + Virtualization

Combine both for the best of both worlds — fetch pages on demand and virtualize the rendered list:

function VirtualInfiniteFeed() {
  const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(/* ... */)
  const allItems = data?.pages.flatMap(p => p.items) ?? []

  const parentRef = useRef<HTMLDivElement>(null)
  const virtualizer = useVirtualizer({
    count: hasNextPage ? allItems.length + 1 : allItems.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80,
    overscan: 5,
  })

  // Load next page when last virtual item becomes visible
  useEffect(() => {
    const lastItem = virtualizer.getVirtualItems().at(-1)
    if (!lastItem) return
    if (lastItem.index >= allItems.length - 1 && hasNextPage) {
      fetchNextPage()
    }
  }, [virtualizer.getVirtualItems(), hasNextPage, allItems.length])

  return (
    <div ref={parentRef} style={{ height: 800, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map(vItem => (
          <div
            key={vItem.key}
            ref={virtualizer.measureElement}
            data-index={vItem.index}
            style={{ position: 'absolute', top: 0, width: '100%', transform: `translateY(${vItem.start}px)` }}
          >
            {vItem.index >= allItems.length
              ? <Spinner />
              : <FeedItem item={allItems[vItem.index]} />}
          </div>
        ))}
      </div>
    </div>
  )
}