React Virtualized Infinite Scroll in 2026: @tanstack/virtual with Infinite Queries
Build high-performance React virtualized lists with @tanstack/virtual and React Query infinite queries: virtual row height, dynamic sizing, infinite scroll, and skeleton loading.
React Virtualized Infinite Scroll in 2026: @tanstack/virtual with Infinite Queries
Rendering 10,000 list items crashes browsers. Rendering 50 at a time with pagination frustrates users who want to scroll. Virtualization is the middle ground: render only the items currently visible in the viewport, but let the user scroll through the entire dataset. Combined with React Query's infinite queries, you get seamless infinite scroll with only the visible rows in the DOM.
This post builds the complete implementation: @tanstack/virtual for row virtualization, useInfiniteQuery for paginated data fetching, an intersection observer that loads more pages as the user scrolls, variable-height row support, and skeleton loading states.
Setup
npm install @tanstack/react-virtual @tanstack/react-query
Basic Virtualized List
// components/VirtualList/VirtualList.tsx
"use client";
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface VirtualListProps<T> {
items: T[];
estimatedItemHeight?: number;
renderItem: (item: T, index: number) => React.ReactNode;
overscan?: number; // Items to render beyond viewport
}
export function VirtualList<T>({
items,
estimatedItemHeight = 64,
renderItem,
overscan = 5,
}: VirtualListProps<T>) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimatedItemHeight,
overscan,
});
return (
// Scrollable container โ must have a fixed height
<div
ref={parentRef}
className="overflow-auto"
style={{ height: "600px" }}
>
{/* Total scrollable height (virtual) */}
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderItem(items[virtualRow.index], virtualRow.index)}
</div>
))}
</div>
</div>
);
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Variable Height Rows with measureElement
When rows have different heights (e.g., activity feed items with varying content):
// components/VirtualList/DynamicVirtualList.tsx
"use client";
import { useRef, useEffect, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface DynamicVirtualListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
estimatedItemHeight?: number;
}
export function DynamicVirtualList<T>({
items,
renderItem,
estimatedItemHeight = 80,
}: DynamicVirtualListProps<T>) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimatedItemHeight,
// measureElement: tells the virtualizer the actual rendered height
// Called automatically via ref callback on each row
measureElement:
typeof window !== "undefined" && navigator.userAgent.indexOf("Firefox") === -1
? (element) => element?.getBoundingClientRect().height
: undefined,
overscan: 3,
});
return (
<div
ref={parentRef}
className="overflow-auto"
style={{ height: "600px" }}
>
<div
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
// ref callback: virtualizer measures actual height after render
ref={virtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderItem(items[virtualRow.index], virtualRow.index)}
</div>
))}
</div>
</div>
);
}
Infinite Scroll with React Query
Combining virtualization with useInfiniteQuery for seamless data loading:
// components/InfiniteContactList/InfiniteContactList.tsx
"use client";
import { useRef, useEffect, useCallback } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
interface Contact {
id: string;
name: string;
email: string;
company: string;
avatarUrl: string | null;
lastActivityAt: string;
}
interface ContactsPage {
contacts: Contact[];
nextCursor: string | null;
total: number;
}
async function fetchContacts(cursor?: string): Promise<ContactsPage> {
const params = new URLSearchParams({ limit: "30" });
if (cursor) params.set("cursor", cursor);
const res = await fetch(`/api/contacts?${params}`);
if (!res.ok) throw new Error("Failed to fetch contacts");
return res.json();
}
const ITEM_HEIGHT = 72; // px โ fixed height for this component
export function InfiniteContactList() {
const parentRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useInfiniteQuery({
queryKey: ["contacts"],
queryFn: ({ pageParam }) => fetchContacts(pageParam as string | undefined),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
// Flatten all pages into a single array
const allContacts = data?.pages.flatMap((page) => page.contacts) ?? [];
const totalCount = data?.pages[0]?.total ?? 0;
// Virtualizer
const virtualizer = useVirtualizer({
count: hasNextPage ? allContacts.length + 1 : allContacts.length, // +1 for sentinel
getScrollElement: () => parentRef.current,
estimateSize: () => ITEM_HEIGHT,
overscan: 5,
});
const virtualItems = virtualizer.getVirtualItems();
const lastVirtualItem = virtualItems[virtualItems.length - 1];
// Load more when the last virtual item is the sentinel row
useEffect(() => {
if (!lastVirtualItem) return;
if (
lastVirtualItem.index >= allContacts.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [lastVirtualItem, hasNextPage, isFetchingNextPage, allContacts.length, fetchNextPage]);
if (isLoading) {
return <ContactListSkeleton count={10} />;
}
if (isError) {
return (
<div role="alert" className="p-4 bg-red-50 rounded-lg text-red-700 text-sm">
Failed to load contacts. Please refresh.
</div>
);
}
return (
<div>
<p className="text-sm text-gray-500 mb-3">
{allContacts.length} of {totalCount} contacts
</p>
<div
ref={parentRef}
className="overflow-auto border border-gray-200 rounded-lg"
style={{ height: "600px" }}
>
<div
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
{virtualItems.map((virtualRow) => {
const contact = allContacts[virtualRow.index];
const isLoaderRow = virtualRow.index > allContacts.length - 1;
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${ITEM_HEIGHT}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
<div className="flex items-center justify-center h-full">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
) : (
<ContactRow contact={contact} />
)}
</div>
);
})}
</div>
</div>
</div>
);
}
function ContactRow({ contact }: { contact: Contact }) {
return (
<div className="flex items-center gap-3 px-4 h-full border-b border-gray-100 hover:bg-gray-50 transition-colors">
{contact.avatarUrl ? (
<img
src={contact.avatarUrl}
alt={contact.name}
className="h-9 w-9 rounded-full flex-shrink-0 object-cover"
/>
) : (
<div className="h-9 w-9 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<span className="text-blue-700 text-sm font-medium">
{contact.name[0]}
</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{contact.name}</p>
<p className="text-xs text-gray-400 truncate">{contact.email}</p>
</div>
<div className="flex-shrink-0 text-right">
<p className="text-xs text-gray-500 truncate max-w-32">{contact.company}</p>
</div>
</div>
);
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Skeleton Loading Component
// components/InfiniteContactList/ContactListSkeleton.tsx
function ContactListSkeleton({ count }: { count: number }) {
return (
<div className="border border-gray-200 rounded-lg divide-y divide-gray-100">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3 animate-pulse">
<div className="h-9 w-9 rounded-full bg-gray-200 flex-shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-3 bg-gray-200 rounded w-32" />
<div className="h-2 bg-gray-100 rounded w-48" />
</div>
<div className="h-2 bg-gray-100 rounded w-20" />
</div>
))}
</div>
);
}
Virtualized Table with Sticky Header
// components/VirtualTable/VirtualTable.tsx
"use client";
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { flexRender, useReactTable, getCoreRowModel, ColumnDef } from "@tanstack/react-table";
interface VirtualTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
rowHeight?: number;
}
export function VirtualTable<T>({
data,
columns,
rowHeight = 48,
}: VirtualTableProps<T>) {
const parentRef = useRef<HTMLDivElement>(null);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const { rows } = table.getRowModel();
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan: 5,
});
const virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? totalSize - virtualRows[virtualRows.length - 1].end
: 0;
return (
<div
ref={parentRef}
className="overflow-auto border border-gray-200 rounded-lg"
style={{ height: "600px" }}
>
<table className="w-full border-collapse">
{/* Sticky header */}
<thead className="sticky top-0 z-10 bg-white border-b border-gray-200">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide"
style={{ width: header.getSize() }}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{/* Spacer for virtual rows above viewport */}
{paddingTop > 0 && (
<tr><td style={{ height: `${paddingTop}px` }} /></tr>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<tr
key={row.id}
className="border-b border-gray-100 hover:bg-gray-50 transition-colors"
style={{ height: `${rowHeight}px` }}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-2 text-sm text-gray-900">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
{/* Spacer for virtual rows below viewport */}
{paddingBottom > 0 && (
<tr><td style={{ height: `${paddingBottom}px` }} /></tr>
)}
</tbody>
</table>
</div>
);
}
Performance Notes
| Scenario | Items in DOM | Without Virtualization | With Virtualization |
|---|---|---|---|
| 1,000 items, 64px rows | All 1,000 | ~150ms render | ~15ms render |
| 10,000 items, 64px rows | All 10,000 | Crash / 5s+ | ~15ms render |
| 100,000 items | All 100,000 | Out of memory | ~15ms render |
| Dynamic heights | Variable | Expensive | Measured per row |
Key rule: with estimateSize, the virtualizer uses estimates until rows are measured. Set estimatedItemHeight as close to actual as possible to minimize layout shift.
Cost and Timeline
| Component | Timeline | Cost (USD) |
|---|---|---|
| Basic virtualized list | 0.5โ1 day | $400โ$800 |
| Infinite scroll + React Query | 1โ2 days | $800โ$1,600 |
| Dynamic row heights | 0.5โ1 day | $400โ$800 |
| Virtualized table with TanStack Table | 1โ2 days | $800โ$1,600 |
| Full virtualized data grid | 1.5โ2 weeks | $6,000โ$10,000 |
See Also
- React Table TanStack โ Full table features with virtualization
- React Query Server State โ Infinite queries and caching
- SaaS Activity Feed โ Virtualized feed with real-time updates
- Next.js Performance Optimization โ Page-level performance strategies
Working With Viprasol
We build high-performance data-heavy UIs for SaaS products โ from virtualized lists through full data grids with sorting, filtering, and infinite scroll. Our team has shipped virtualized interfaces handling 100,000+ row datasets.
What we deliver:
- Virtualized list and table components with @tanstack/virtual
- Infinite scroll wired to paginated APIs with React Query
- Dynamic row height measurement for variable-content lists
- Skeleton loading states and error boundaries
- Performance profiling before and after
Explore our web development services or contact us to add virtualization to your data-heavy UI.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.