When Your React App Tries to Render 50,000 Rows and Your Browser Says "Nope!"
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.