Back to Blog
ReactVirtualizationPerformanceLists

React Virtualization Patterns

Render large lists efficiently with virtualization. From react-window to tanstack-virtual to custom implementations.

B
Bootspring Team
Engineering
February 17, 2021
7 min read

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 memory

react-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.

Share this article

Help spread the word about Bootspring