Back to Blog

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.

Viprasol Tech Team
January 25, 2027
13 min read

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

ScenarioItems in DOMWithout VirtualizationWith Virtualization
1,000 items, 64px rowsAll 1,000~150ms render~15ms render
10,000 items, 64px rowsAll 10,000Crash / 5s+~15ms render
100,000 itemsAll 100,000Out of memory~15ms render
Dynamic heightsVariableExpensiveMeasured 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

ComponentTimelineCost (USD)
Basic virtualized list0.5โ€“1 day$400โ€“$800
Infinite scroll + React Query1โ€“2 days$800โ€“$1,600
Dynamic row heights0.5โ€“1 day$400โ€“$800
Virtualized table with TanStack Table1โ€“2 days$800โ€“$1,600
Full virtualized data grid1.5โ€“2 weeks$6,000โ€“$10,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.