Skip to content

Infinite Scroll Virtualized Table

Combine virtual scrolling with infinite loading — render huge paginated datasets smoothly without blowing up the DOM.

Open in
Preview with Controlled State
Open in

The Infinite Scroll Virtualized Table combines two orthogonal performance strategies:

  • Virtualization — only the rows visible in the viewport are actually rendered to the DOM, regardless of how many are loaded. Critical for datasets in the thousands.
  • Infinite loading — rows are fetched lazily as the user scrolls, so you don’t need to know (or transfer) the full dataset upfront.

You get both by composing DataTableVirtualizedBody (which provides the virtualized scroll container and fires onScrolledBottom) with DataTableVirtualizedLoadingMore (the composable “loading more” child that self-gates on its own isFetching prop).

This example paginates through a mocked 5,000-row pool with artificial latency so you can see the virtualizer and infinite loader working together.

Install the DataTable core and add-ons for this example:

pnpm dlx shadcn@latest add @niko-table/data-table @niko-table/data-table-virtualized @niko-table/data-table-search-filter @niko-table/data-table-view-menu @niko-table/data-table-column-sort

First time using @niko-table? See the Installation Guide to set up the registry.

We are going to build a virtualized table that paginates through a large list of products. Here’s what our data looks like:

type Product = {
id: string
name: string
category: string
price: number
stock: number
status: "in-stock" | "low-stock" | "out-of-stock"
}

Like the non-virtualized Infinite Scroll Table, the mock generator is fully deterministic — index-based values, no Math.random(), no Date.now(). This keeps the example safe to copy-paste into a Next.js / RSC app without triggering SSR/CSR hydration mismatches.

mock-data.ts
const CATEGORIES = [
"Electronics",
"Clothing",
"Food",
"Books",
"Sports",
"Home",
"Toys",
"Beauty",
] as const
function generateMockProducts(count: number): Product[] {
return Array.from({ length: count }, (_, i) => {
const stock = (i * 37) % 150
const price = ((i * 13) % 490) + 10
return {
id: `product-${i + 1}`,
name: `Product ${i + 1}`,
category: CATEGORIES[i % CATEGORIES.length],
price,
stock,
status:
stock === 0 ? "out-of-stock" : stock < 20 ? "low-stock" : "in-stock",
}
})
}
// Larger pool than the non-virtualized example — 5,000 rows split
// into 50 pages of 100, so virtualization's value is visible.
const TOTAL_POOL = generateMockProducts(5000)
const PAGE_SIZE = 100
const FAKE_LATENCY_MS = 800
function fetchNextPage(offset: number): Promise<Product[]> {
return new Promise(resolve => {
setTimeout(() => {
resolve(TOTAL_POOL.slice(offset, offset + PAGE_SIZE))
}, FAKE_LATENCY_MS)
})
}

Identical to the non-virtualized example — three pieces of state (loaded, isLoading, isFetching), one loadMore handler guarded against re-entry, one derived hasMore boolean.

const [loaded, setLoaded] = React.useState<Product[]>([])
const [isLoading, setIsLoading] = React.useState(true)
const [isFetching, setIsFetching] = React.useState(false)
const hasMore = loaded.length < TOTAL_POOL.length
React.useEffect(() => {
let cancelled = false
void fetchNextPage(0).then(page => {
if (cancelled) return
setLoaded(page)
setIsLoading(false)
})
return () => {
cancelled = true
}
}, [])
const loadMore = React.useCallback(async () => {
if (isFetching || !hasMore) return
setIsFetching(true)
try {
const nextPage = await fetchNextPage(loaded.length)
setLoaded(prev => [...prev, ...nextPage])
} finally {
setIsFetching(false)
}
}, [isFetching, hasMore, loaded.length])

<DataTableVirtualizedBody /> with onNearEnd + <DataTableVirtualizedLoadingMore />

Section titled “<DataTableVirtualizedBody /> with onNearEnd + <DataTableVirtualizedLoadingMore />”

The virtualized body offers a strictly better prefetch primitive for infinite scroll: onNearEnd — virtualizer-index-driven, not scroll-event-driven. It fires when the last rendered virtual row is within prefetchThreshold rows of the end of the loaded dataset.

Why is this better than onScrolledBottom?

ScenarioonScrolledBottomonNearEnd
Slow scroll reaches bottomfiresfires (earlier)
Fast wheel scroll past bottomfires once, gap before resolvefires early, no gap
Scrollbar drag to 90%may missfires as soon as virtualizer re-renders
Initial load — data < viewportnever fires (no scroll)fires immediately
scrollToIndex() jumpno scroll eventfires on re-render
Row height changes dynamicallypixel math driftsindex math is exact
infinite-scroll-virtualized-table.tsx
"use client"
import * as React from "react"
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"
import { DataTable } from "@/components/niko-table/core/data-table"
import {
DataTableVirtualizedHeader,
DataTableVirtualizedBody,
DataTableVirtualizedEmptyBody,
DataTableVirtualizedSkeleton,
DataTableVirtualizedLoadingMore,
} from "@/components/niko-table/core/data-table-virtualized-structure"
import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"
import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"
import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"
export function InfiniteScrollVirtualizedTable() {
// ... state + loadMore from the previous section ...
return (
<DataTableRoot
data={loaded}
columns={columns}
isLoading={isLoading}
config={{ initialPageSize: 5000 }}
>
<DataTableToolbarSection>
<DataTableSearchFilter placeholder="Search products..." />
<DataTableViewMenu />
</DataTableToolbarSection>
<DataTable height={600} className="rounded-lg border">
<DataTableVirtualizedHeader />
<DataTableVirtualizedBody
prefetchThreshold={15}
onNearEnd={() => {
if (hasMore && !isFetching) void loadMore()
}}
>
<DataTableVirtualizedSkeleton rows={15} />
<DataTableVirtualizedEmptyBody />
<DataTableVirtualizedLoadingMore isFetching={isFetching}>
Loading more products...
</DataTableVirtualizedLoadingMore>
</DataTableVirtualizedBody>
</DataTable>
</DataTableRoot>
)
}

Virtualized tables always require a fixed height on <DataTable> — the virtualizer needs a scroll container with a known viewport size to compute which rows to render.

<DataTable height={600} className="rounded-lg border">
{/* ... */}
</DataTable>

How DataTableVirtualizedLoadingMore works with the virtualizer

Section titled “How DataTableVirtualizedLoadingMore works with the virtualizer”

DataTableVirtualizedLoadingMore is a plain child of TableBody — it sits outside the virtualizer’s row count, not inside it. This matters because:

  1. It doesn’t affect estimateSize math. The virtualizer computes total scroll height from rows.length × estimateSize. If the loading-more row were a virtual row, appending it would shift every subsequent row’s position.
  2. It renders at the visual bottom of the viewport regardless of how far the user has scrolled.
  3. It self-gates on isFetching — render it unconditionally in JSX, it only actually appears when a fetch is in flight.

Both coexist on DataTableVirtualizedBody — you can use either. For infinite scroll, prefer onNearEnd:

  • onNearEnd — virtualizer-index-driven. Fires when the last rendered virtual row is within prefetchThreshold rows of the end. Catches fast scrolls, scrollbar drag, scrollToIndex() jumps, and initial renders where data doesn’t fill the viewport. Fires once per false→true transition (no double-fires).
  • onScrolledBottom — scroll-event-driven. Fires when the user scrolls within scrollThreshold pixels of the bottom. Simpler mental model, works for non-infinite-scroll use cases (e.g. analytics “user reached bottom”), but misses edge cases listed above.

Pattern: wiring TanStack Query’s useInfiniteQuery

Section titled “Pattern: wiring TanStack Query’s useInfiniteQuery”

When you swap the mock data for a real API, this pattern maps cleanly onto TanStack Query’s useInfiniteQuery:

const query = api.products.list.useInfiniteQuery(
{ limit: 100 },
{ getNextPageParam: p => p.nextCursor },
)
const loaded = query.data?.pages.flatMap(p => p.rows) ?? []
<DataTableRoot data={loaded} columns={columns} isLoading={query.isLoading}>
<DataTable height={600}>
<DataTableVirtualizedHeader />
<DataTableVirtualizedBody
prefetchThreshold={15}
onNearEnd={() => {
if (query.hasNextPage && !query.isFetchingNextPage) {
void query.fetchNextPage()
}
}}
>
<DataTableVirtualizedSkeleton rows={15} />
<DataTableVirtualizedEmptyBody />
<DataTableVirtualizedLoadingMore isFetching={query.isFetchingNextPage}>
Loading more products...
</DataTableVirtualizedLoadingMore>
</DataTableVirtualizedBody>
</DataTable>
</DataTableRoot>

Zero per-consumer boilerplate — isFetching comes straight off the query, onNearEnd calls fetchNextPage, and you’re done.

✅ Use Infinite Scroll Virtualized Table when:

  • Your paginated API can return thousands of rows over the full session
  • Rendering every loaded row would bloat the DOM (>1000 rows accumulated)
  • Users expect smooth scrolling through a growing list
  • You already use virtualization for performance reasons

❌ Don’t use it when:

  • Your pool is small (< 1000 rows total) — plain Infinite Scroll Table is simpler
  • You need row DnD — virtualized row DnD + infinite scroll mixes two sources of row-order truth
  • You need variable row heights — the virtualizer works best with consistent row heights