Picture this: It's Friday evening, you're about to wrap up for the weekend, and your product manager casually mentions, "Oh, by the way, users need to see all 50,000 customer records in a single table. Should be easy, right?"
Your browser: crashes Your CPU: screams Your weekend plans: gone
Been there? Yeah, me too. Let me tell you how I learned to make React render massive tables without setting my laptop on fire.
The Problem: More Isnβt Always Better
Here's what happens when you naively try to render 50k table rows:
// DON'T DO THIS (unless you enjoy pain) function NaiveTable({ data }) { return ( <table> <tbody> {data.map(row => ( <tr key={row.id}> <td>{row.name}</td> <td>{row.email}</td> <td>{row.status}</td> </tr> ))} </tbody> </table> ); } // 50,000 DOM nodes later... // Browser: "I quit."
What happens next?
- Your app freezes for 10+ seconds
- Scrolling becomes choppier than a blender with a broken blade
- Memory usage shoots up like a rocket
- Users start questioning their life choices (and yours)
Enter Row Virtualization: Your New Best Friend
Row virtualization is like having a smart waiter at a buffet. Instead of bringing you all 50,000 dishes at once (and crushing the table), they only show you what fits on your plate plus a few extras nearby. Genius, right?
The core idea: Only render the rows that are actually visible in the viewport, plus a small buffer.
Building Your Own Virtualized Table (The Hard Way)
Before we jump to libraries, let's understand what's happening under the hood. Here's a basic implementation:
import React, { useState, useEffect, useRef } from 'react'; function VirtualizedTable({ data, rowHeight = 50 }) { const [scrollTop, setScrollTop] = useState(0); const [containerHeight, setContainerHeight] = useState(400); const containerRef = useRef(); const totalHeight = data.length * rowHeight; const visibleRowCount = Math.ceil(containerHeight / rowHeight); const startIndex = Math.floor(scrollTop / rowHeight); const endIndex = Math.min(startIndex + visibleRowCount + 1, data.length); // Only render visible rows const visibleRows = data.slice(startIndex, endIndex); const handleScroll = (e) => { setScrollTop(e.target.scrollTop); }; return ( <div ref={containerRef} style={{ height: containerHeight, overflow: 'auto' }} onScroll={handleScroll} > {/* Spacer to maintain scroll height */} <div style={{ height: startIndex * rowHeight }} /> {/* Visible rows */} <table> <tbody> {visibleRows.map((row, index) => ( <tr key={row.id} style={{ height: rowHeight }}> <td>{row.name}</td> <td>{row.email}</td> <td>{row.status}</td> </tr> ))} </tbody> </table> {/* Bottom spacer */} <div style={{ height: (data.length - endIndex) * rowHeight }} /> </div> ); }
What's happening here?
- We calculate which rows should be visible based on scroll position
- We add spacers above and below to maintain the correct scroll height
- We only render the visible rows + a small buffer
Result? Smooth scrolling through 50k rows like butter!
The Easy Way: React-Window to the Rescue
Building your own virtualization is fun for learning, but for production apps, let's be smart and use battle-tested libraries. Enter react-window
β it's like having a Ferrari engine for your table.
npm install react-window react-window-infinite-loader
import { FixedSizeList as List } from 'react-window'; function VirtualizedTableWithLibrary({ data }) { const Row = ({ index, style }) => ( <div style={style} className="table-row"> <span>{data[index].name}</span> <span>{data[index].email}</span> <span>{data[index].status}</span> </div> ); return ( <List height={600} // Container height itemCount={data.length} // Total number of rows itemSize={50} // Height of each row width="100%" > {Row} </List> ); }
Boom! 50k rows rendered smoothly with just a few lines of code. It's like magic, but better β it's engineering.
Taking It Up a Notch: Variable Row Heights
Life isn't always uniform. Sometimes your rows have different heights (dynamic content, wrapped text, etc.). Here's how to handle that:
import { VariableSizeList as List } from 'react-window'; function VariableHeightTable({ data }) { // Function to calculate row height const getItemSize = (index) => { // You can calculate based on content const item = data[index]; const baseHeight = 50; const extraHeight = item.description ? 30 : 0; return baseHeight + extraHeight; }; const Row = ({ index, style }) => { const item = data[index]; return ( <div style={style} className="variable-row"> <h4>{item.name}</h4> <p>{item.email}</p> {item.description && <p>{item.description}</p>} </div> ); }; return ( <List height={600} itemCount={data.length} itemSize={getItemSize} // Dynamic sizing! width="100%" > {Row} </List> ); }
Real-World Performance Tips
1. Memoization is Your Friend
const MemoizedRow = React.memo(({ item, style }) => ( <div style={style}> {/* Row content */} </div> ));
2. Optimize Your Data Structure
// Instead of nested objects const badData = data.map(item => ({ ...item, user: { name: item.userName, email: item.userEmail } })); // Flatten for better performance const goodData = data.map(item => ({ id: item.id, userName: item.userName, userEmail: item.userEmail, status: item.status }));
3. Debounce Search/Filter Operations
import { useMemo, useState } from 'react'; import { debounce } from 'lodash'; function SearchableTable({ data }) { const [searchTerm, setSearchTerm] = useState(''); const debouncedSearch = useMemo( () => debounce((term) => setSearchTerm(term), 300), [] ); const filteredData = useMemo( () => data.filter(item => item.name.toLowerCase().includes(searchTerm.toLowerCase()) ), [data, searchTerm] ); return ( <div> <input placeholder="Search..." onChange={(e) => debouncedSearch(e.target.value)} /> <VirtualizedTable data={filteredData} /> </div> ); }
When NOT to Use Virtualization
Virtualization isn't always the answer. Skip it when:
- You have less than 1,000 rows (overhead might not be worth it)
- You need all rows to be searchable by browser (Ctrl+F)
- You're printing the table
- Your rows have complex interactions that depend on other rows
Advanced: Infinite Loading with Virtualization
Want to level up? Combine virtualization with infinite loading:
import InfiniteLoader from 'react-window-infinite-loader'; function InfiniteVirtualTable({ loadMoreData, hasMoreData, data }) { const isItemLoaded = (index) => !!data[index]; const Row = ({ index, style }) => { const item = data[index]; if (!item) { return <div style={style}>Loading...</div>; } return ( <div style={style}> {/* Row content */} </div> ); }; return ( <InfiniteLoader isItemLoaded={isItemLoaded} itemCount={hasMoreData ? data.length + 1 : data.length} loadMoreItems={loadMoreData} > {({ onItemsRendered, ref }) => ( <List ref={ref} height={600} itemCount={hasMoreData ? data.length + 1 : data.length} itemSize={50} onItemsRendered={onItemsRendered} > {Row} </List> )} </InfiniteLoader> ); }
The Performance Numbers That'll Make You Smile
Before virtualization:
- Initial render: 8-12 seconds
- Memory usage: 500MB+
- Scroll FPS: 15-20
- User experience: π‘
After virtualization:
- Initial render: 200-300ms
- Memory usage: 50-80MB
- Scroll FPS: 60
- User experience: π
Libraries Worth Checking Out
- react-window - Lightweight and fast
- react-virtualized - Feature-rich but heavier
- @tanstack/react-virtual - Modern, flexible
- react-viewport-list - Simple and effective
Debugging Virtualized Tables
// Add this to see what's happening function DebugVirtualTable({ data }) { const [debugInfo, setDebugInfo] = useState({}); const onItemsRendered = ({ visibleStartIndex, visibleStopIndex }) => { setDebugInfo({ start: visibleStartIndex, end: visibleStopIndex, rendered: visibleStopIndex - visibleStartIndex + 1 }); }; return ( <div> <div>Rendering rows {debugInfo.start} to {debugInfo.end} ({debugInfo.rendered} total)</div> <List onItemsRendered={onItemsRendered} // ... other props > {/* Row component */} </List> </div> ); }
Wrapping Up: From Browser Killer to Smooth Operator
Virtualization transformed my 50k-row table from a browser-crashing nightmare into a smooth, responsive experience. It's like the difference between trying to eat an entire pizza at once versus enjoying it slice by slice.
Key takeaways:
- Only render what's visible
- Use libraries like react-window for production
- Optimize your data structures
- Profile your performance
- Remember: premature optimization is evil, but necessary optimization is genius
Pro tip: Start simple, measure performance, then optimize. Don't virtualize everything just because you can β sometimes 500 regular rows work just fine.
Next time someone asks you to render 50k rows, just smile and say, "Challenge accepted." Because now you know the secret: it's not about rendering more rows, it's about rendering smarter.
Got questions about virtualization? Hit me up! I've probably broken it, fixed it, and optimized it at least three times.
P.S. - Yes, I've actually had to explain to a PM why rendering 100k rows at once is a bad idea. The conversation involved drawing diagrams on a whiteboard and a lot of coffee.