Virtualization renders only visible items, enabling smooth scrolling of large lists. Here's how to implement it.
Why Virtualization?#
1// Problem: Rendering 10,000 items
2function SlowList({ items }: { items: Item[] }) {
3 return (
4 <ul>
5 {items.map(item => (
6 <li key={item.id}>{item.name}</li>
7 ))}
8 </ul>
9 );
10}
11// Creates 10,000 DOM nodes = slow initial render, high memory
12
13// Solution: Render only visible items (~20-50)
14function FastList({ items }: { items: Item[] }) {
15 // Only renders items in viewport
16 return <VirtualizedList items={items} />;
17}
18// Creates ~30 DOM nodes = fast render, low memoryreact-window#
1import { FixedSizeList } from 'react-window';
2
3interface Item {
4 id: string;
5 name: string;
6}
7
8function VirtualList({ items }: { items: Item[] }) {
9 const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
10 <div style={style} className="list-item">
11 {items[index].name}
12 </div>
13 );
14
15 return (
16 <FixedSizeList
17 height={400}
18 width="100%"
19 itemCount={items.length}
20 itemSize={50}
21 >
22 {Row}
23 </FixedSizeList>
24 );
25}
26
27// With memoization
28const Row = memo(({ data, index, style }: {
29 data: Item[];
30 index: number;
31 style: React.CSSProperties;
32}) => (
33 <div style={style}>
34 {data[index].name}
35 </div>
36));
37
38function OptimizedList({ items }: { items: Item[] }) {
39 return (
40 <FixedSizeList
41 height={400}
42 width="100%"
43 itemCount={items.length}
44 itemSize={50}
45 itemData={items}
46 >
47 {Row}
48 </FixedSizeList>
49 );
50}Variable Size List#
1import { VariableSizeList } from 'react-window';
2
3function VariableList({ items }: { items: Item[] }) {
4 const listRef = useRef<VariableSizeList>(null);
5
6 // Calculate item height based on content
7 const getItemSize = (index: number) => {
8 const item = items[index];
9 const baseHeight = 50;
10 const lineHeight = 20;
11 const lines = Math.ceil(item.description.length / 50);
12 return baseHeight + (lines * lineHeight);
13 };
14
15 // Reset cache when items change
16 useEffect(() => {
17 listRef.current?.resetAfterIndex(0);
18 }, [items]);
19
20 const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
21 <div style={style} className="variable-item">
22 <h3>{items[index].name}</h3>
23 <p>{items[index].description}</p>
24 </div>
25 );
26
27 return (
28 <VariableSizeList
29 ref={listRef}
30 height={400}
31 width="100%"
32 itemCount={items.length}
33 itemSize={getItemSize}
34 estimatedItemSize={80}
35 >
36 {Row}
37 </VariableSizeList>
38 );
39}Virtual Grid#
1import { FixedSizeGrid } from 'react-window';
2
3function VirtualGrid({ items, columnCount = 4 }: {
4 items: Item[];
5 columnCount?: number;
6}) {
7 const rowCount = Math.ceil(items.length / columnCount);
8
9 const Cell = ({ columnIndex, rowIndex, style }: {
10 columnIndex: number;
11 rowIndex: number;
12 style: React.CSSProperties;
13 }) => {
14 const index = rowIndex * columnCount + columnIndex;
15 if (index >= items.length) return null;
16
17 const item = items[index];
18
19 return (
20 <div style={style} className="grid-cell">
21 <img src={item.image} alt={item.name} />
22 <span>{item.name}</span>
23 </div>
24 );
25 };
26
27 return (
28 <FixedSizeGrid
29 height={600}
30 width={800}
31 columnCount={columnCount}
32 columnWidth={200}
33 rowCount={rowCount}
34 rowHeight={250}
35 >
36 {Cell}
37 </FixedSizeGrid>
38 );
39}TanStack Virtual#
1import { useVirtualizer } from '@tanstack/react-virtual';
2
3function TanStackList({ items }: { items: Item[] }) {
4 const parentRef = useRef<HTMLDivElement>(null);
5
6 const virtualizer = useVirtualizer({
7 count: items.length,
8 getScrollElement: () => parentRef.current,
9 estimateSize: () => 50,
10 overscan: 5,
11 });
12
13 return (
14 <div
15 ref={parentRef}
16 style={{ height: 400, overflow: 'auto' }}
17 >
18 <div
19 style={{
20 height: `${virtualizer.getTotalSize()}px`,
21 position: 'relative',
22 }}
23 >
24 {virtualizer.getVirtualItems().map(virtualItem => (
25 <div
26 key={virtualItem.key}
27 style={{
28 position: 'absolute',
29 top: 0,
30 left: 0,
31 width: '100%',
32 height: `${virtualItem.size}px`,
33 transform: `translateY(${virtualItem.start}px)`,
34 }}
35 >
36 {items[virtualItem.index].name}
37 </div>
38 ))}
39 </div>
40 </div>
41 );
42}
43
44// Dynamic sizing
45function DynamicList({ items }: { items: Item[] }) {
46 const parentRef = useRef<HTMLDivElement>(null);
47
48 const virtualizer = useVirtualizer({
49 count: items.length,
50 getScrollElement: () => parentRef.current,
51 estimateSize: () => 100,
52 measureElement: (element) => element.getBoundingClientRect().height,
53 });
54
55 return (
56 <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
57 <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
58 {virtualizer.getVirtualItems().map(virtualItem => (
59 <div
60 key={virtualItem.key}
61 data-index={virtualItem.index}
62 ref={virtualizer.measureElement}
63 style={{
64 position: 'absolute',
65 top: 0,
66 left: 0,
67 width: '100%',
68 transform: `translateY(${virtualItem.start}px)`,
69 }}
70 >
71 <DynamicContent item={items[virtualItem.index]} />
72 </div>
73 ))}
74 </div>
75 </div>
76 );
77}Infinite Scroll#
1import { FixedSizeList } from 'react-window';
2import InfiniteLoader from 'react-window-infinite-loader';
3
4function InfiniteList({
5 hasNextPage,
6 isNextPageLoading,
7 items,
8 loadNextPage,
9}: {
10 hasNextPage: boolean;
11 isNextPageLoading: boolean;
12 items: Item[];
13 loadNextPage: () => Promise<void>;
14}) {
15 const itemCount = hasNextPage ? items.length + 1 : items.length;
16
17 const isItemLoaded = (index: number) => !hasNextPage || index < items.length;
18
19 const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
20 if (!isItemLoaded(index)) {
21 return <div style={style}>Loading...</div>;
22 }
23
24 return (
25 <div style={style}>
26 {items[index].name}
27 </div>
28 );
29 };
30
31 return (
32 <InfiniteLoader
33 isItemLoaded={isItemLoaded}
34 itemCount={itemCount}
35 loadMoreItems={isNextPageLoading ? () => {} : loadNextPage}
36 >
37 {({ onItemsRendered, ref }) => (
38 <FixedSizeList
39 ref={ref}
40 height={400}
41 width="100%"
42 itemCount={itemCount}
43 itemSize={50}
44 onItemsRendered={onItemsRendered}
45 >
46 {Row}
47 </FixedSizeList>
48 )}
49 </InfiniteLoader>
50 );
51}Custom Virtualization#
1function useVirtualList<T>({
2 items,
3 itemHeight,
4 containerHeight,
5 overscan = 3,
6}: {
7 items: T[];
8 itemHeight: number;
9 containerHeight: number;
10 overscan?: number;
11}) {
12 const [scrollTop, setScrollTop] = useState(0);
13
14 const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
15 const endIndex = Math.min(
16 items.length - 1,
17 Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
18 );
19
20 const visibleItems = items.slice(startIndex, endIndex + 1).map((item, index) => ({
21 item,
22 index: startIndex + index,
23 style: {
24 position: 'absolute' as const,
25 top: (startIndex + index) * itemHeight,
26 height: itemHeight,
27 width: '100%',
28 },
29 }));
30
31 const totalHeight = items.length * itemHeight;
32
33 const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
34 setScrollTop(e.currentTarget.scrollTop);
35 };
36
37 return { visibleItems, totalHeight, handleScroll };
38}
39
40// Usage
41function CustomVirtualList({ items }: { items: Item[] }) {
42 const { visibleItems, totalHeight, handleScroll } = useVirtualList({
43 items,
44 itemHeight: 50,
45 containerHeight: 400,
46 });
47
48 return (
49 <div
50 style={{ height: 400, overflow: 'auto' }}
51 onScroll={handleScroll}
52 >
53 <div style={{ height: totalHeight, position: 'relative' }}>
54 {visibleItems.map(({ item, index, style }) => (
55 <div key={item.id} style={style}>
56 {item.name}
57 </div>
58 ))}
59 </div>
60 </div>
61 );
62}Windowed Table#
1import { useVirtualizer } from '@tanstack/react-virtual';
2
3function VirtualTable({ data, columns }: {
4 data: Record<string, any>[];
5 columns: { key: string; header: string; width: number }[];
6}) {
7 const parentRef = useRef<HTMLDivElement>(null);
8
9 const rowVirtualizer = useVirtualizer({
10 count: data.length,
11 getScrollElement: () => parentRef.current,
12 estimateSize: () => 40,
13 overscan: 10,
14 });
15
16 return (
17 <div ref={parentRef} style={{ height: 500, overflow: 'auto' }}>
18 <table style={{ width: '100%', borderCollapse: 'collapse' }}>
19 <thead style={{ position: 'sticky', top: 0, background: 'white' }}>
20 <tr>
21 {columns.map(col => (
22 <th key={col.key} style={{ width: col.width }}>
23 {col.header}
24 </th>
25 ))}
26 </tr>
27 </thead>
28 <tbody>
29 <tr style={{ height: rowVirtualizer.getTotalSize() }}>
30 <td colSpan={columns.length} style={{ padding: 0 }}>
31 <div style={{ position: 'relative', height: '100%' }}>
32 {rowVirtualizer.getVirtualItems().map(virtualRow => (
33 <div
34 key={virtualRow.key}
35 style={{
36 position: 'absolute',
37 top: virtualRow.start,
38 width: '100%',
39 height: virtualRow.size,
40 display: 'flex',
41 }}
42 >
43 {columns.map(col => (
44 <div key={col.key} style={{ width: col.width }}>
45 {data[virtualRow.index][col.key]}
46 </div>
47 ))}
48 </div>
49 ))}
50 </div>
51 </td>
52 </tr>
53 </tbody>
54 </table>
55 </div>
56 );
57}Scroll Restoration#
1function VirtualListWithRestore({ items }: { items: Item[] }) {
2 const listRef = useRef<FixedSizeList>(null);
3 const scrollKey = 'list-scroll-position';
4
5 // Restore scroll position
6 useEffect(() => {
7 const savedPosition = sessionStorage.getItem(scrollKey);
8 if (savedPosition && listRef.current) {
9 listRef.current.scrollTo(parseInt(savedPosition, 10));
10 }
11 }, []);
12
13 // Save scroll position
14 const handleScroll = ({ scrollOffset }: { scrollOffset: number }) => {
15 sessionStorage.setItem(scrollKey, String(scrollOffset));
16 };
17
18 return (
19 <FixedSizeList
20 ref={listRef}
21 height={400}
22 width="100%"
23 itemCount={items.length}
24 itemSize={50}
25 onScroll={handleScroll}
26 >
27 {Row}
28 </FixedSizeList>
29 );
30}Best Practices#
Implementation:
✓ Use overscan for smooth scrolling
✓ Memoize row components
✓ Handle dynamic heights properly
✓ Reset virtualizer on data changes
Performance:
✓ Avoid inline styles in rows
✓ Use itemData for shared data
✓ Debounce scroll handlers
✓ Profile with React DevTools
UX:
✓ Show loading placeholders
✓ Preserve scroll position
✓ Handle empty states
✓ Support keyboard navigation
Conclusion#
Virtualization enables smooth rendering of large lists by only rendering visible items. Use react-window for simple cases, TanStack Virtual for dynamic sizing, and implement infinite scroll for paginated data. Always memoize row components and handle scroll restoration.