Infinite Scroll Virtualized Table
Combine virtual scrolling with infinite loading — render huge paginated datasets smoothly without blowing up the DOM.
Preview with Controlled State
View Full State Object
[]
{}{
"pageIndex": 0,
"pageSize": 5000
}{}{
"left": [],
"right": []
}Introduction
Section titled “Introduction”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.
Installation
Section titled “Installation”Install the DataTable core and add-ons for this example:
First time using
@niko-table? See the Installation Guide to set up the registry.
Prerequisites
Section titled “Prerequisites”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"}Implementation
Section titled “Implementation”Deterministic mock data generator
Section titled “Deterministic mock data generator”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.
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 = 100const 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) })}Pagination state + loadMore handler
Section titled “Pagination state + loadMore handler”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?
| Scenario | onScrolledBottom | onNearEnd |
|---|---|---|
| Slow scroll reaches bottom | fires | fires (earlier) |
| Fast wheel scroll past bottom | fires once, gap before resolve | fires early, no gap |
| Scrollbar drag to 90% | may miss | fires as soon as virtualizer re-renders |
| Initial load — data < viewport | never fires (no scroll) | fires immediately |
scrollToIndex() jump | no scroll event | fires on re-render |
| Row height changes dynamically | pixel math drifts | index math is exact |
"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> )}Required: Fixed Height
Section titled “Required: Fixed Height”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:
- It doesn’t affect
estimateSizemath. The virtualizer computes total scroll height fromrows.length × estimateSize. If the loading-more row were a virtual row, appending it would shift every subsequent row’s position. - It renders at the visual bottom of the viewport regardless of how far the user has scrolled.
- It self-gates on
isFetching— render it unconditionally in JSX, it only actually appears when a fetch is in flight.
onNearEnd vs onScrolledBottom
Section titled “onNearEnd vs onScrolledBottom”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 withinprefetchThresholdrows 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 withinscrollThresholdpixels 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.
When to Use
Section titled “When to Use”✅ 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
Next Steps
Section titled “Next Steps”- Infinite Scroll Table — non-virtualized variant for smaller pools
- Virtualization Table — virtualization without infinite scroll
- Server-Side Table — traditional pagination with server-driven sort/filter