Infinite Scroll Virtualized Table
Combine virtual scrolling with infinite loading — render huge paginated datasets smoothly without blowing up the DOM.
"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 { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"import { DataTableColumnSortMenu } from "@/components/niko-table/components/data-table-column-sort"import { DataTableEmptyIcon, DataTableEmptyMessage, DataTableEmptyFilteredMessage, DataTableEmptyTitle, DataTableEmptyDescription,} from "@/components/niko-table/components/data-table-empty-state"import { DataTableFacetedFilter } from "@/components/niko-table/components/data-table-faceted-filter"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"import { FILTER_VARIANTS, SYSTEM_COLUMN_IDS,} from "@/components/niko-table/lib/constants"import type { DataTableColumnDef } from "@/components/niko-table/types"import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { ChevronDown, ChevronRight, PackageSearch, SearchX } from "lucide-react"
// Example data typeinterface Product { id: string name: string category: string brand: string price: number stock: number rating: number revenue: number status: "in-stock" | "low-stock" | "out-of-stock" releaseDate: Date}
/** * Deterministic mock data generator — no Math.random, no Date.now. * * WHY DETERMINISTIC: See [infinite-scroll-table.tsx] for the full * explanation. TL;DR: index-based values are safe to run at module * scope / useState initializers under Next.js RSC + React Strict * Mode, while Math.random would trigger a hydration mismatch on * every cell on first render. */const CATEGORIES = [ "Electronics", "Clothing", "Food", "Books", "Sports", "Home", "Toys", "Beauty",] as const
const BRANDS = [ "Apple", "Samsung", "Nike", "Adidas", "Sony", "LG", "Dell", "HP",] 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], brand: BRANDS[i % BRANDS.length], price, stock, rating: ((i * 7) % 5) + 1, revenue: price * stock, status: stock === 0 ? "out-of-stock" : stock < 20 ? "low-stock" : "in-stock", releaseDate: new Date(2024, (i * 3) % 12, ((i * 7) % 28) + 1), } })}
const statusOptions = [ { label: "In Stock", value: "in-stock" }, { label: "Low Stock", value: "low-stock" }, { label: "Out of Stock", value: "out-of-stock" },]
// Expanded content component for product detailsfunction ProductDetails({ product }: { product: Product }) { return ( <div className="bg-muted/30 p-4"> <h3 className="font-semibold">Product Details</h3> <div className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2"> <div> <div> <span className="text-muted-foreground">ID:</span> {product.id} </div> <div> <span className="text-muted-foreground">Category:</span>{" "} {product.category} </div> </div> <div> <div> <span className="text-muted-foreground">Price:</span> $ {product.price.toFixed(2)} </div> <div> <span className="text-muted-foreground">Stock:</span>{" "} {product.stock} </div> </div> </div> </div> )}
// Larger pool so virtualization value is actually demonstrated —// 5,000 rows, 50 pages of 100 each.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) })}
export default function InfiniteScrollVirtualizedTableExample() { 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])
const columns: DataTableColumnDef<Product>[] = React.useMemo( () => [ { id: SYSTEM_COLUMN_IDS.EXPAND, header: () => null, cell: ({ row }) => { if (!row.getCanExpand()) return null return ( <Button variant="ghost" size="sm" onClick={row.getToggleExpandedHandler()} className="h-6 w-6 p-0 hover:bg-accent" > {row.getIsExpanded() ? ( <ChevronDown className="h-3.5 w-3.5" /> ) : ( <ChevronRight className="h-3.5 w-3.5" /> )} </Button> ) }, size: 50, enableSorting: false, enableHiding: false, meta: { expandedContent: (product: Product) => ( <ProductDetails product={product} /> ), }, }, { accessorKey: "name", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Name" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="font-medium">{row.getValue("name")}</div> ), }, { accessorKey: "category", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Category" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="capitalize">{row.getValue("category")}</div> ), }, { accessorKey: "price", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Price" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </DataTableColumnHeader> ), cell: ({ row }) => { const price = row.getValue("price") as number return <div className="font-mono">${price.toFixed(2)}</div> }, }, { accessorKey: "stock", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Stock" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="text-right">{row.getValue("stock")}</div> ), }, { accessorKey: "status", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Status" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} /> </DataTableColumnHeader> ), cell: ({ row }) => { const status = row.getValue("status") as string return ( <Badge variant={ status === "in-stock" ? "default" : status === "low-stock" ? "secondary" : "destructive" } > {status} </Badge> ) }, filterFn: (row, id, value: string[]) => { return value.includes(row.getValue(id)) }, }, { accessorKey: "brand", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Brand" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="capitalize">{row.getValue("brand")}</div> ), }, { accessorKey: "rating", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Rating" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </DataTableColumnHeader> ), cell: ({ row }) => { const rating = row.getValue("rating") as number return ( <div className="flex items-center gap-1"> <span>{rating}</span> <span className="text-yellow-500">★</span> </div> ) }, }, { accessorKey: "revenue", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Revenue" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </DataTableColumnHeader> ), cell: ({ row }) => { const revenue = row.getValue("revenue") as number return <div className="font-mono">${revenue.toLocaleString()}</div> }, }, { accessorKey: "releaseDate", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Release Date" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => { const date = row.getValue("releaseDate") as Date return <span>{date.toLocaleDateString()}</span> }, }, ], [], )
return ( <DataTableRoot data={loaded} columns={columns} isLoading={isLoading} config={{ // Large page size so client-side pagination never clips // the virtualized viewport — the scroll container is // what drives row loading here, not the pagination bar. initialPageSize: 5000, enableExpanding: true, }} getRowCanExpand={row => row.original.stock > 0} > <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search products..." /> <DataTableFacetedFilter accessorKey="status" title="Status" options={statusOptions} /> <DataTableViewMenu /> </DataTableToolbarSection> {/* Virtualized tables REQUIRE a fixed height on <DataTable> for the virtualizer scroll container to exist. `onNearEnd` is virtualizer-index-driven (not scroll-event-driven) so it catches fast scrolls, scrollbar drag, and initial renders where data doesn't fill the viewport — strictly better than `onScrolledBottom` for infinite scroll. */} <DataTable height={600} className="rounded-lg border"> <DataTableVirtualizedHeader /> <DataTableVirtualizedBody prefetchThreshold={15} onNearEnd={() => { if (hasMore && !isFetching) void loadMore() }} > <DataTableVirtualizedSkeleton rows={15} /> <DataTableVirtualizedEmptyBody> <DataTableEmptyMessage> <DataTableEmptyIcon> <PackageSearch className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle>No products found</DataTableEmptyTitle> <DataTableEmptyDescription> There are no products to display at this time. </DataTableEmptyDescription> </DataTableEmptyMessage> <DataTableEmptyFilteredMessage> <DataTableEmptyIcon> <SearchX className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle>No matches found</DataTableEmptyTitle> <DataTableEmptyDescription> Try adjusting your search to find what you're looking for. </DataTableEmptyDescription> </DataTableEmptyFilteredMessage> </DataTableVirtualizedEmptyBody> {/* DataTableVirtualizedLoadingMore sits OUTSIDE the virtualizer's row count — it does not affect `estimateSize` math. Self-gates on `isFetching`. */} <DataTableVirtualizedLoadingMore isFetching={isFetching}> Loading more products... </DataTableVirtualizedLoadingMore> </DataTableVirtualizedBody> </DataTable> <div className="px-1 pt-2 text-right text-xs text-muted-foreground"> Loaded {loaded.length} of {TOTAL_POOL.length} products {!hasMore && loaded.length > 0 && " — end of results"} </div> </DataTableRoot> )}Preview with Controlled State
View Full State Object
[]
{}{
"pageIndex": 0,
"pageSize": 5000
}{}{
"left": [],
"right": []
}"use client"
import * as React from "react"import { useState } from "react"import type { PaginationState, SortingState, ColumnFiltersState, VisibilityState, ExpandedState, ColumnPinningState,} from "@tanstack/react-table"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 { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"import { DataTableColumnSortMenu } from "@/components/niko-table/components/data-table-column-sort"import { DataTableEmptyIcon, DataTableEmptyMessage, DataTableEmptyFilteredMessage, DataTableEmptyTitle, DataTableEmptyDescription,} from "@/components/niko-table/components/data-table-empty-state"import { DataTableFacetedFilter } from "@/components/niko-table/components/data-table-faceted-filter"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"import { FILTER_VARIANTS, SYSTEM_COLUMN_IDS,} from "@/components/niko-table/lib/constants"import type { DataTableColumnDef } from "@/components/niko-table/types"import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card"import { ChevronDown, ChevronRight, PackageSearch, SearchX } from "lucide-react"
// Example data typeinterface Product { id: string name: string category: string brand: string price: number stock: number rating: number revenue: number status: "in-stock" | "low-stock" | "out-of-stock" releaseDate: Date}
// Deterministic generator — see infinite-scroll-table.tsx for the// full explanation of why we avoid Math.random / Date.now here.const CATEGORIES = [ "Electronics", "Clothing", "Food", "Books", "Sports", "Home", "Toys", "Beauty",] as const
const BRANDS = [ "Apple", "Samsung", "Nike", "Adidas", "Sony", "LG", "Dell", "HP",] 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], brand: BRANDS[i % BRANDS.length], price, stock, rating: ((i * 7) % 5) + 1, revenue: price * stock, status: stock === 0 ? "out-of-stock" : stock < 20 ? "low-stock" : "in-stock", releaseDate: new Date(2024, (i * 3) % 12, ((i * 7) % 28) + 1), } })}
const statusOptions = [ { label: "In Stock", value: "in-stock" }, { label: "Low Stock", value: "low-stock" }, { label: "Out of Stock", value: "out-of-stock" },]
const brandOptions = [ { label: "Apple", value: "Apple" }, { label: "Samsung", value: "Samsung" }, { label: "Nike", value: "Nike" }, { label: "Adidas", value: "Adidas" }, { label: "Sony", value: "Sony" }, { label: "LG", value: "LG" }, { label: "Dell", value: "Dell" }, { label: "HP", value: "HP" },]
// Expanded content component for product detailsfunction ProductDetails({ product }: { product: Product }) { return ( <div className="bg-muted/30 p-4"> <h3 className="font-semibold">Product Details</h3> <div className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2"> <div> <div> <span className="text-muted-foreground">ID:</span> {product.id} </div> <div> <span className="text-muted-foreground">Category:</span>{" "} {product.category} </div> </div> <div> <div> <span className="text-muted-foreground">Price:</span> $ {product.price.toFixed(2)} </div> <div> <span className="text-muted-foreground">Stock:</span>{" "} {product.stock} </div> </div> </div> </div> )}
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) })}
export default function InfiniteScrollVirtualizedTableStateExample() { // Infinite-scroll data state const [loaded, setLoaded] = React.useState<Product[]>([]) const [isLoading, setIsLoading] = React.useState(true) const [isFetching, setIsFetching] = React.useState(false)
// Fully controlled table state const [globalFilter, setGlobalFilter] = useState<string | object>("") const [sorting, setSorting] = useState<SortingState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}) const [expanded, setExpanded] = useState<ExpandedState>({}) const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({ left: [], right: [], }) const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 5000, })
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])
const resetAllState = React.useCallback(() => { setGlobalFilter("") setSorting([]) setColumnFilters([]) setColumnVisibility({}) setExpanded({}) setColumnPinning({ left: [], right: [] }) setPagination({ pageIndex: 0, pageSize: 5000 }) }, [])
const columns: DataTableColumnDef<Product>[] = React.useMemo( () => [ { id: SYSTEM_COLUMN_IDS.EXPAND, header: () => null, cell: ({ row }) => { if (!row.getCanExpand()) return null return ( <Button variant="ghost" size="sm" onClick={row.getToggleExpandedHandler()} className="h-6 w-6 p-0 hover:bg-accent" > {row.getIsExpanded() ? ( <ChevronDown className="h-3.5 w-3.5" /> ) : ( <ChevronRight className="h-3.5 w-3.5" /> )} </Button> ) }, size: 50, enableSorting: false, enableHiding: false, meta: { expandedContent: (product: Product) => ( <ProductDetails product={product} /> ), }, }, { accessorKey: "name", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Name" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="font-medium">{row.getValue("name")}</div> ), }, { accessorKey: "category", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Category" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="capitalize">{row.getValue("category")}</div> ), }, { accessorKey: "price", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Price" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </DataTableColumnHeader> ), cell: ({ row }) => { const price = row.getValue("price") as number return <div className="font-mono">${price.toFixed(2)}</div> }, }, { accessorKey: "stock", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Stock" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="text-right">{row.getValue("stock")}</div> ), }, { accessorKey: "status", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Status" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} /> </DataTableColumnHeader> ), cell: ({ row }) => { const status = row.getValue("status") as string return ( <Badge variant={ status === "in-stock" ? "default" : status === "low-stock" ? "secondary" : "destructive" } > {status} </Badge> ) }, filterFn: (row, id, value: string[]) => { return value.includes(row.getValue(id)) }, }, { accessorKey: "brand", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Brand" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="capitalize">{row.getValue("brand")}</div> ), }, { accessorKey: "rating", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Rating" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </DataTableColumnHeader> ), cell: ({ row }) => { const rating = row.getValue("rating") as number return ( <div className="flex items-center gap-1"> <span>{rating}</span> <span className="text-yellow-500">★</span> </div> ) }, }, { accessorKey: "revenue", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Revenue" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </DataTableColumnHeader> ), cell: ({ row }) => { const revenue = row.getValue("revenue") as number return <div className="font-mono">${revenue.toLocaleString()}</div> }, }, { accessorKey: "releaseDate", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Release Date" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => { const date = row.getValue("releaseDate") as Date return <span>{date.toLocaleDateString()}</span> }, }, ], [], )
return ( <div className="w-full space-y-4"> <DataTableRoot data={loaded} columns={columns} isLoading={isLoading} config={{ enableExpanding: true, }} getRowCanExpand={row => row.original.stock > 0} state={{ globalFilter, sorting, columnFilters, columnVisibility, expanded, columnPinning, pagination, }} onGlobalFilterChange={value => { setGlobalFilter(value) setPagination(prev => ({ ...prev, pageIndex: 0 })) }} onSortingChange={setSorting} onColumnFiltersChange={filters => { setColumnFilters(filters) setPagination(prev => ({ ...prev, pageIndex: 0 })) }} onColumnVisibilityChange={setColumnVisibility} onExpandedChange={setExpanded} onColumnPinningChange={setColumnPinning} onPaginationChange={setPagination} > <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search products..." /> <DataTableFacetedFilter accessorKey="status" title="Status" options={statusOptions} /> <DataTableFacetedFilter accessorKey="brand" title="Brand" options={brandOptions} /> <DataTableViewMenu /> </DataTableToolbarSection> <DataTable height={600} className="rounded-lg border"> <DataTableVirtualizedHeader /> <DataTableVirtualizedBody prefetchThreshold={15} onNearEnd={() => { if (hasMore && !isFetching) void loadMore() }} > <DataTableVirtualizedSkeleton rows={15} /> <DataTableVirtualizedEmptyBody> <DataTableEmptyMessage> <DataTableEmptyIcon> <PackageSearch className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle>No products found</DataTableEmptyTitle> <DataTableEmptyDescription> There are no products to display at this time. </DataTableEmptyDescription> </DataTableEmptyMessage> <DataTableEmptyFilteredMessage> <DataTableEmptyIcon> <SearchX className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle>No matches found</DataTableEmptyTitle> <DataTableEmptyDescription> Try adjusting your search to find what you're looking for. </DataTableEmptyDescription> </DataTableEmptyFilteredMessage> </DataTableVirtualizedEmptyBody> <DataTableVirtualizedLoadingMore isFetching={isFetching}> Loading more products... </DataTableVirtualizedLoadingMore> </DataTableVirtualizedBody> </DataTable> <div className="px-1 pt-2 text-right text-xs text-muted-foreground"> Loaded {loaded.length} of {TOTAL_POOL.length} products {!hasMore && loaded.length > 0 && " — end of results"} </div> </DataTableRoot>
{/* State Display for demonstration */} <Card> <CardHeader> <CardTitle>Infinite Scroll Virtualized Table State</CardTitle> <CardDescription> Live view of the controlled table state with{" "} {TOTAL_POOL.length.toLocaleString()} total products </CardDescription> <CardAction> <Button variant="outline" size="sm" onClick={resetAllState}> Reset All State </Button> </CardAction> </CardHeader> <CardContent className="space-y-4"> <div className="grid gap-2 text-xs text-muted-foreground"> <div className="flex justify-between"> <span className="font-medium">Search Query:</span> <span className="text-foreground"> {typeof globalFilter === "string" ? globalFilter || "None" : "Mixed Filters"} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Total Items:</span> <span className="text-foreground"> {TOTAL_POOL.length.toLocaleString()} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Loaded Rows:</span> <span className="text-foreground"> {loaded.length.toLocaleString()} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Active Filters:</span> <span className="text-foreground"> {columnFilters.length || "0 (Search Only)"} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Expanded Rows:</span> <span className="text-foreground"> {Object.keys(expanded).length} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Sorting:</span> <span className="text-foreground"> {sorting.length > 0 ? sorting .map(s => `${s.id} ${s.desc ? "desc" : "asc"}`) .join(", ") : "None"} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Page:</span> <span className="text-foreground"> {pagination.pageIndex + 1} (Size: {pagination.pageSize}) </span> </div>
<div className="flex justify-between"> <span className="font-medium">Hidden Columns:</span> <span className="text-foreground"> { Object.values(columnVisibility).filter(v => v === false) .length } </span> </div>
<div className="flex justify-between"> <span className="font-medium">Pinned Columns:</span> <span className="text-foreground"> {columnPinning.left?.length || 0} Left,{" "} {columnPinning.right?.length || 0} Right </span> </div> </div>
{/* Detailed state (collapsible) */} <details className="border-t pt-4"> <summary className="cursor-pointer text-xs font-medium hover:text-foreground"> View Full State Object </summary> <div className="mt-4 space-y-3 text-xs"> <div> <strong>Sorting:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(sorting, null, 2)} </pre> </div> <div> <strong>Expanded Rows:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(expanded, null, 2)} </pre> </div> <div> <strong>Pagination:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(pagination, null, 2)} </pre> </div> <div> <strong>Column Visibility:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(columnVisibility, null, 2)} </pre> </div> <div> <strong>Column Pinning:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(columnPinning, null, 2)} </pre> </div> </div> </details> </CardContent> </Card> </div> )}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