Virtual Scrolling in React: tanstack-virtual, Window Sizing, Dynamic Heights
Render 100,000 rows without freezing the browser. A practical guide to tanstack-virtual, variable row heights, and window sizing in React apps.
Why Virtual Scrolling Exists (and When You Actually Need It)
Rendering 10,000 <div> nodes into the DOM is not a React problem — it's a browser problem. The layout engine has to calculate position, paint, and composite every single element whether it's on screen or not. At around 500–1,000 rows you'll start seeing jank. At 10,000 you're looking at multi-second freezes on mid-range hardware. That's just geometry.
Virtual scrolling fixes this by maintaining a fixed pool of rendered DOM nodes and swapping their content as the user scrolls. Only the visible rows — plus a small overscan buffer — exist in the DOM at any given time. The scroll container itself holds a large spacer element that gives the scrollbar accurate proportions, so the user experience feels identical to a real list.
Honestly, most apps don't need this. If your table tops out at a few hundred rows, save yourself the complexity. But if you're building a log viewer, a data grid, an activity feed, or any feature where the server can return unbounded results — virtual scrolling isn't optional. It's the only way to keep the thread responsive.
Worth noting: React 18's concurrent features (useTransition, useDeferredValue) help with *rendering* performance, but they don't reduce the DOM node count. You still need virtualisation for genuinely large lists. The two techniques complement each other; they don't replace each other.
Setting Up @tanstack/react-virtual
@tanstack/react-virtual v3 (released in 2023, current as of v3.10) is the right choice for most React projects. It's framework-agnostic at its core, has zero dependencies, ships under 5 kB gzipped, and handles fixed heights, variable heights, horizontal lists, and grid layouts from a single unified API. React Window is older and more limited. React Virtuoso has a larger API surface. TanStack Virtual hits the sweet spot.
npm install @tanstack/react-virtual
# or
pnpm add @tanstack/react-virtualThe mental model is straightforward: you give the virtualiser a container ref, the total item count, and a size estimator. It returns a list of virtualItems — each with an index, start offset, and measured size — that you render inside a positioned container. The library tracks scroll position through a native event listener and re-computes the visible window on every frame.
One more thing — the library does not manage data fetching or windowed loading. That's intentional. You're expected to bring your own data layer (React Query, SWR, a simple array) and feed the item count to the virtualiser. This keeps the library lean and lets you compose it with whatever remote data strategy you're already using.
Fixed-Height Lists: The Simple Case
Fixed row height is the happy path. Because every row is exactly the same height, the virtualiser can calculate any item's position with pure arithmetic — no measurement needed, no ResizeObserver overhead. If your use case allows it, lock the row height.
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
const ROW_HEIGHT = 48; // px
export function FixedList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
});
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
{/* Total height spacer */}
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index]}
</div>
))}
</div>
</div>
);
}The position: absolute + transform: translateY() pattern is deliberate. It pulls each row out of normal document flow, which eliminates reflow cascades when the visible set changes. Don't use top: virtualRow.start directly — transform is composited on the GPU and skips layout, which is meaningfully faster during fast scrolls.
That said, the container's height (600px above) is hardcoded. In practice you almost always want this to fill the available viewport space, which brings us to window sizing.
Window Sizing: Making the List Fill the Viewport
Hardcoding a pixel height is fine for demos but breaks in real layouts where the sidebar, header, or browser chrome changes dimensions. What you actually want is a scroll container that fills whatever vertical space remains after the rest of your layout takes its share. There are two approaches: CSS and JavaScript measurement.
The CSS approach is often enough. If your list lives in a flex column layout, give the parent flex: 1 and overflow: hidden, then make the scroll container height: 100% with overflow-y: auto. This works because the flex parent establishes a finite height for the child to fill.
// Layout shell
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<header style={{ height: 64 }}>My App</header>
<main style={{ flex: 1, overflow: 'hidden' }}>
{/* Scroll container fills the remaining space */}
<div ref={parentRef} style={{ height: '100%', overflow: 'auto' }}>
{/* virtualizer output here */}
</div>
</main>
</div>When CSS isn't sufficient — maybe you're in a resizable panel or a dialog — measure with ResizeObserver. TanStack Virtual v3 exposes an observeElementOffset and observeElementRect option for exactly this. You can also use the useWindowVirtualizer export if the scroll container is the window itself (no wrapper div), which avoids the need to measure anything at all.
import { useWindowVirtualizer } from '@tanstack/react-virtual';
export function WindowList({ items }: { items: string[] }) {
const virtualizer = useWindowVirtualizer({
count: items.length,
estimateSize: () => 48,
overscan: 5,
});
return (
<div style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index]}
</div>
))}
</div>
);
}useWindowVirtualizer is perfect for pages where the list is the page — think Twitter-style feeds, log streams, infinite galleries. Because the scroll container is the native window, you also get correct behavior with browser back/forward scroll restoration for free.
Dynamic Heights: Measuring Variable-Size Rows
Variable heights are where virtual scrolling gets genuinely tricky. You can't know the height of a row containing arbitrary text, images, or nested components until it's been rendered and measured. The virtualiser has to make an initial estimate, render the item, measure the real height, then correct the layout. Done naively, this causes visible jumps.
TanStack Virtual handles this with the measureElement callback. You attach a ref to each row's DOM node, and when the element mounts (or changes size), the virtualiser re-measures and re-calculates downstream positions. The overscan option keeps a buffer of off-screen rows pre-rendered, which reduces the chances of users ever seeing un-measured blank space.
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
export function DynamicList({ items }: { items: { text: string }[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // initial guess in px
measureElement: (el) => el.getBoundingClientRect().height,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index].text}
</div>
))}
</div>
</div>
);
}Quick aside: the data-index attribute on each row is required when using measureElement as a ref callback. The virtualiser reads it to know which item's size to update. Miss it and your measurements will silently apply to the wrong rows.
In practice, your estimateSize guess matters more than you'd think. A wildly wrong estimate causes the scrollbar thumb to jump around as rows get measured, which feels broken. If rows are mostly short with occasional long ones, start the estimate at the median, not the average. If you're loading posts with embedded images, 200 px is usually a safer floor than 80 px.
Infinite Scroll and Prepend Patterns
Virtual scrolling and infinite scroll are natural partners. As the user approaches the bottom of the list, you fetch more items and append them to the array — the virtualiser picks them up automatically because it reads count on every render. The tricky case is *prepend*: when new items arrive at the top (like a real-time chat or a log tail), you need to offset the scroll position to prevent the view from jumping.
// Keep scroll position stable when prepending items
const prevItemCount = useRef(items.length);
useLayoutEffect(() => {
const added = items.length - prevItemCount.current;
if (added > 0 && scrollDir === 'up') {
const totalAdded = added * ESTIMATED_ROW_HEIGHT;
parentRef.current?.scrollBy({ top: totalAdded, behavior: 'instant' });
}
prevItemCount.current = items.length;
}, [items.length]);Use useLayoutEffect here, not useEffect. You need the scroll correction to happen synchronously before the browser paints — otherwise the user sees a flash of the wrong position. This is one of the few cases where useLayoutEffect is genuinely the right call.
For intersection-observer-based load triggering, add a sentinel div at the bottom of the list (outside the virtualiser's container) and observe it. When it becomes visible, fire your next page fetch. Combine with React Query's useInfiniteQuery and you get cursor-based pagination with zero manual state management. Look, this pattern covers 90% of real-world infinite list requirements — keep it simple before reaching for specialised solutions.
If you're building out a large-scale UI like this and want pre-styled list containers or skeleton states to slot in around your virtual list, browse components on Empire UI. The library includes card grids, feed layouts, and skeleton loaders that pair well with any virtualisation layer.
Common Gotchas and How to Avoid Them
The most common bug: forgetting that the scroll container needs an explicit height and overflow: auto. Without a finite height, the browser never triggers scroll events, and the virtualiser renders everything. This is easy to miss in nested flex layouts where the parent is growing to fit content instead of constraining it.
Second most common: using key={virtualRow.index} on rows that can be reordered or filtered. When the underlying array changes and indices shift, React reuses the existing DOM nodes but with new content — which confuses measureElement because the old measurements are now stale. Use a stable item ID as the key instead: key={items[virtualRow.index].id}.
CSS padding on the scroll container breaks position calculations. The virtualiser measures offset from the container's scroll origin, which doesn't account for padding. Use a wrapper div inside the container for visual spacing instead, or set paddingStart and paddingEnd in the virtualiser config — those get baked into the total size calculation correctly.
// Don't do this:
<div ref={parentRef} style={{ padding: '16px', overflow: 'auto', height: '600px' }}>
// Do this instead:
<div ref={parentRef} style={{ overflow: 'auto', height: '600px' }}>
<div style={{ padding: '16px 16px 0' }}> {/* top/side padding via wrapper */}
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{/* rows */}
</div>
</div>
</div>Finally — don't put expensive components directly inside the row render without memoisation. The virtualiser re-renders all currently visible rows on every scroll event. If each row makes an API call or runs a heavy computation on every render, you're trading DOM node count for computation cost. Wrap row content in React.memo and stabilise any callback props with useCallback. The Empire UI blog has more on React memoisation patterns if you want to go deeper on that angle.
FAQ
React Window is older and well-established but requires you to know row heights upfront and doesn't support dynamic measurement out of the box. TanStack Virtual v3 handles fixed heights, variable heights, horizontal lists, and grids with a single unified API, and it's actively maintained. For new projects, reach for TanStack Virtual.
Usually no. If you're showing 20–50 items per page, pagination is simpler and often better for accessibility and SEO. Virtual scrolling becomes necessary when you're displaying hundreds or thousands of items simultaneously without a page break — think log viewers, real-time feeds, or large data tables.
Most flickering is caused by a bad estimateSize value. When the real measured height differs dramatically from the estimate, the virtualiser corrects positions and the scrollbar jumps. Set your estimate closer to the actual median row height, increase overscan to 5–10, and use useLayoutEffect for any post-render scroll corrections.
Yes, but the scroll container must be the modal's content div, not the window. Pass that div's ref to getScrollElement and give it an explicit height (or max-height) with overflow: auto. Avoid useWindowVirtualizer in modal contexts — it listens to the window scroll, which is blocked by the modal's scroll trap.