Rendering long lists fast
You've probably hit the wall: a user opens a modal with a dropdown containing 10,000 items, or you load a data table with all historical records at once, and the browser freezes for 3 seconds. Rendering long lists fast isn't just about showing data—it's about keeping your app responsive even when the dataset explodes. In this post, I'll walk through four production-tested patterns that let you handle thousands of rows without sacrificing user experience.
Why long lists break your app
Before we fix it, let's understand the problem. When React renders a list of 10,000 items, the browser:
- Creates DOM nodes for every single row
- Runs layout and paint passes on all of them
- Stores them in memory forever, even rows scrolled off-screen
This tanks frame rate, burns memory, and makes JavaScript unresponsive while the browser's main thread is busy. The solution isn't to render smarter—it's to render less.
Virtualize — Render only visible rows with TanStack Virtual
Virtualization is the game-changer. The core idea: only render the rows currently visible in the viewport, plus a small buffer zone above and below. As the user scrolls, you swap out rows in the DOM dynamically. With virtualization, rendering 10,000 rows feels like rendering 20.
I've used TanStack Virtual (formerly react-virtual) on production e-commerce platforms, and it's rock-solid. Here's a real-world example: a CRM showing 50,000 customer records in a scrollable table.
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
export function CustomerTable({ customers }) {
const parentRef = useRef(null)
const virtualizer = useVirtualizer({
count: customers.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Row height in px
overscan: 10, // Buffer: render 10 extra rows beyond viewport
})
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
return (
<div>
<div>
{virtualItems.map((virtualItem) => (
<div>
</div>
))}
</div>
</div>
)
}
function CustomerRow({ customer }) {
return (
<div>
<strong>{customer.name}</strong> – {customer.email}
</div>
)
}That's it. With virtualization, you go from rendering 50,000 DOM nodes to rendering maybe 15. Scrolling stays smooth at 60 fps even on mid-range devices. The trade-off: you lose native browser features like find-in-page (Ctrl+F) for off-screen items, but that's rarely a blocker.
Stable keys — Use IDs. Index keys break state when items reorder.
This is where I've seen production bugs. You virtualize the list, but if rows have state (expanded details, form inputs, selections), reordering breaks everything. Why? React uses keys to match old elements to new ones. If you use array indices as keys, React gets confused when the array order changes.
Real scenario: a trading platform shows a list of open positions. Users can sort by price or profit. If your list uses index keys, clicking a row to expand details, then sorting by price, will expand a different row because React re-used the DOM node.
Always use stable, unique identifiers:
// ❌ Bad: index keys break state on reorder
{items.map((item, index) => (
))}
// ✅ Good: ID keys preserve state
{items.map((item) => (
))}Pair this with virtualization: TanStack Virtual takes the unique key and uses it internally. If you reorder the source array, the virtualized rows stay in sync with the correct items:
const virtualItems = virtualizer.getVirtualItems()
{virtualItems.map((vItem) => (
<div>
</div>
))}This pattern saved us from a nasty bug on a ride-hailing app where sorting active rides by distance would corrupt selected filters on each ride card.
Memoize rows — React.memo on row components with stable props
Even with virtualization, if the parent re-renders for unrelated reasons, it'll re-render every visible row component. Expensive row logic (data transformations, calculations, heavy formatting) will run again unnecessarily. React.memo prevents this.
The catch: React.memo uses shallow comparison on props. If you pass inline objects or functions, it's useless because they're new on every render.
Here's the right way:
// ✅ Memoized row with stable props
const CustomerRow = React.memo(function CustomerRow({ customer, isSelected, onSelect }) {
return (
<div> onSelect(customer.id)}>
<strong>{customer.name}</strong>
{isSelected && <span> (selected)</span>}
</div>
)
})
export function CustomerTable({ customers, selectedId, onSelect }) {
// ... virtualization setup ...
return (
<div>
{virtualItems.map((vItem) => (
))}
</div>
)
}Why does this matter? On a SaaS admin panel with 5,000 users, if the table parent re-renders (maybe a filter changed), without memo, you'd recalculate formatting for all 5,000 users in memory. With memo, only the visible 20 rows that actually changed get re-rendered.
Use useCallback to keep callbacks stable:
const onSelect = useCallback((customerId) => {
setSelectedId(customerId)
}, []) // No deps = stable foreverPaginate on the server — Sometimes the best list is a shorter one
The most underrated optimization: don't load all 10,000 rows at once. Paginate on the server.
Virtualization handles display beautifully, but if you still load the entire dataset upfront, you're downloading megabytes, parsing JSON, and allocating memory for rows the user will never see. On a slow network, that's brutal.
Real example: a travel booking site had a search results page showing all 8,000 matching flights. Loading took 4 seconds. We changed it to load 50 at a time, with a "Load more" button. First page load dropped to 600ms, and users got answers instantly.
Combine pagination with virtualization for the best UX:
export function FlightSearch() {
const [flights, setFlights] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
const loadMore = async () => {
setLoading(true)
const res = await fetch(`/api/flights?page=${page}&limit=50`)
const newFlights = await res.json()
setFlights((prev) => [...prev, ...newFlights])
setPage((p) => p + 1)
setHasMore(newFlights.length === 50)
setLoading(false)
}
const parentRef = useRef(null)
const virtualizer = useVirtualizer({
count: flights.length + (hasMore ? 1 : 0),
getScrollElement: () => parentRef.current,
estimateSize: () => 80,
overscan: 5,
})
// Detect when user scrolls near the end
const virtualItems = virtualizer.getVirtualItems()
useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1]
if (lastItem?.index >= flights.length - 10 && hasMore && !loading) {
loadMore()
}
}, [virtualItems, flights.length, hasMore, loading])
return (
<div>
{/* Virtualized rows */}
{virtualItems.map((vItem) => (
<div>
{flights[vItem.index] ? (
) : (
<div>Loading...</div>
)}
</div>
))}
</div>
)
}This pattern (infinite scroll with virtualization) is standard in production apps. Users see fast, responsive results. Network bandwidth stays reasonable. Memory usage stays bounded.
Common mistakes
- Forgetting overscan. Without a buffer (overscan), rows flicker when scrolling fast. Set it to 10–20 rows depending on row height and scroll speed.
- Measuring row heights wrong. If
estimateSizeis inaccurate, the scrollbar thumb will jump as you scroll. For variable-height rows, usemeasureElementcallback or pass exact heights. - Memoizing too aggressively. Don't memo expensive static lists. Memo has overhead; only use it when re-renders are actually wasteful. Profile first.
- Loading too much data up-front "just in case". If users search for flights and get 50,000 results, pagination saves everyone pain. Trust that most users won't click past page 5 anyway.
- Mixing virtualization with horizontal scrolling carelessly. TanStack Virtual defaults to vertical. Horizontal lists need extra config or separate libraries.
Key takeaways
- Virtualize with TanStack Virtual to render only visible rows. This alone cuts DOM size by 99% on large lists.
- Use stable, unique IDs for keys—never indices. Reordering rows will corrupt state otherwise.
- Memoize row components with React.memo and stable props to prevent wasteful re-renders when the parent updates.
- Paginate on the server. Don't load 10,000 rows if you can load 50 and fetch more on scroll. Network and memory both win.
- Combine all four patterns: virtualize + stable keys + memoized rows + server pagination. This is how production apps handle real data.






Comments
Loading comments…