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.
Table of Contents
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>
)
}
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>
)
}