How to Virtualize Long Lists in React
This guide is part of DOM Size & List Virtualization, within Rendering & CSS Performance. A React component that maps over 5,000 orders, log lines or messages renders 5,000 rows, each with several elements. The initial render and commit take hundreds of milliseconds, the DOM holds tens of thousands of nodes, every state change that touches the list makes React reconcile thousands of components, and the browser recalculates style and layout across all of them. Scrolling may be smooth, but filtering, sorting and selecting rows are slow, and INP suffers.
Virtualization (or "windowing") renders only the rows that are visible, plus a small buffer, inside a container sized to the full list height. As the user scrolls, rows outside the window are removed and new ones rendered. DOM size stays constant regardless of list length. Libraries such as TanStack Virtual, react-window and react-virtuoso implement it; the main decisions are fixed vs dynamic row heights, overscan, and how to preserve accessibility and browser features.
Rapid Diagnosis
- Count rendered rows in React DevTools or the DOM; hundreds or thousands of rows indicate a candidate.
- Profile initial render and interactions with the React Profiler and the Performance panel.
- Check DOM size in Lighthouse; long lists usually dominate.
- Check whether users need the full list at once; pagination may be simpler.
Root Cause Analysis
1. Rendering every row. Cost proportional to list length.
2. Reconciliation on every change. Selecting or filtering re-renders many row components.
3. Style and layout over many nodes. Any class change near the list triggers large recalculations.
4. Memory. Thousands of components and DOM nodes increase GC pressure.
Step-by-Step Resolution
1. Virtualize with fixed row heights
import { useVirtualizer } from '@tanstack/react-virtual';
function Orders({ rows }) {
const parentRef = React.useRef(null);
const virtualizer = useVirtualizer({
count: rows.length, getScrollElement: () => parentRef.current, estimateSize: () => 48, overscan: 10,
});
return (
<div ref={parentRef} style={{ height: '70vh', overflow: 'auto' }} role="list" aria-label="Orders">
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((v) => (
<div key={v.key} role="listitem" aria-setsize={rows.length} aria-posinset={v.index + 1}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 48, transform: `translateY(${v.start}px)` }}>
<OrderRow order={rows[v.index]} />
</div>
))}
</div>
</div>
);
}
// trade-off: overscan of 10 rows hides blank areas on fast scrolls at the cost of
// rendering ~20 extra rows; lower it on slow devices if rendering is expensive.
2. Handle dynamic heights with measurement
const virtualizer = useVirtualizer({
count: rows.length, getScrollElement: () => parentRef.current,
estimateSize: () => 80, // reasonable estimate
measureElement: (el) => el.getBoundingClientRect().height,
});
// In the row: <div data-index={v.index} ref={virtualizer.measureElement} ...>
// trade-off: measuring rows reads layout; libraries batch measurements, but very
// complex rows can still cause visible size corrections while scrolling.
3. Memoize rows
Wrap row components in React.memo and pass stable props, so scrolling and parent updates only render rows whose data changed.
4. Window-level scrolling for page lists
For feeds that scroll with the page rather than a container, use the library's window virtualizer (useWindowVirtualizer) so the list integrates with normal page scrolling.
Verification
Check that DOM row count stays constant while scrolling (roughly visible rows plus overscan). Profile initial render and common interactions; both should no longer scale with list length. Scroll quickly on a throttled device and confirm no prolonged blank areas. Test keyboard navigation and a screen reader for list semantics.
Worked Example: A Logistics Dashboard
A logistics company's dashboard showed up to 8,000 shipments in a table. Rendering took 2.3 seconds on the operations team's laptops, and selecting a row took 400ms because the selection state lived in the parent and every row re-rendered. The team virtualized the table with TanStack Virtual (fixed 44px rows, overscan 12), memoized rows with selection passed as a boolean prop, and moved filtering to a Web Worker. Initial render dropped to 90ms, row selection to 16ms, and INP p75 for the page from 420ms to 110ms. They added a search box and keyboard shortcuts to make up for find-in-page no longer finding offscreen rows.
Keeping Virtualized Lists Usable
Virtualization changes browser behaviour users rely on. Find-in-page cannot find rows that are not rendered; provide an in-app search or filter. Scroll restoration after navigation needs the list to restore its offset once data is loaded; store the scroll offset or the first visible index and restore it after the virtualizer mounts. Anchor links to specific rows need scrollToIndex. Printing prints only rendered rows; provide a print view or export. Screen readers need aria-setsize and aria-posinset (or aria-rowcount and aria-rowindex for grids) so they announce the full list size.
Common Mistakes
- Virtualizing short lists. Under a few hundred rows, the complexity rarely pays off.
- Unmemoized rows. Scrolling re-renders every visible row on each update.
- Wrong size estimates. Scrollbar jumps and content shifting during scroll.
- Ignoring accessibility. Screen readers announce only the rendered items.
Edge Cases
Sticky headers in tables. Render headers outside the virtualized body, or use the library's sticky support.
Grids (2D virtualization). Virtualize rows and columns for very wide data grids; libraries support both axes.
Infinite loading. Combine virtualization with data fetching when the last rows come into view.
Server rendering. Render the first window of rows on the server for SEO and fast first paint; the virtualizer takes over on hydration.
FAQ
Which React virtualization library should I use?
TanStack Virtual is headless and flexible; react-window is small and simple for fixed sizes; react-virtuoso handles dynamic heights and grouped lists with less setup. Choose based on layout needs.
How many rows justify virtualization?
As a rule of thumb, several hundred rows of moderate complexity, or any list that can grow unbounded. Measure render time and interaction cost first.
Does virtualization work with CSS grid layouts?
Yes, by virtualizing rows of grid items, with each virtual row rendering a fixed number of columns based on container width.
Why do I see blank areas when scrolling fast?
Rows render after scroll events; on slow devices they can lag. Increase overscan slightly and keep row components cheap to render.
Does virtualization reduce memory use?
Yes, significantly — only rendered rows exist as components and DOM nodes. Data for all rows still lives in memory.
Can I use content-visibility instead?
For lists up to a few hundred rows, content-visibility: auto on row groups gives much of the rendering benefit while keeping all rows in the DOM. For thousands of rows, virtualization is more effective.
How do I keep keyboard navigation working?
Handle arrow keys at the list level, track the active index in state, and call scrollToIndex to bring the active row into view before focusing it.
Is virtualization compatible with React Server Components?
The virtualized list is a client component because it depends on scroll position. Server components can fetch data and pass it to the client list.
Should filtering happen before or inside the virtualized list?
Before. Filter and sort the data array (in a memoized selector or a Web Worker for large datasets), then pass the result to the virtualizer. The virtualizer only needs the final row count and items.
Can I animate rows in a virtualized list?
Keep animations to transform and opacity on rendered rows. Layout animations across many rows defeat the purpose and can confuse the virtualizer’s measurements.
Related
- Fixing the excessive DOM size warning — other ways to shrink the DOM.
- Using content-visibility: auto on long pages — the lighter alternative.
- React performance — memoization and rendering costs.