Infinite Scroll Table
Load more rows on demand as the user scrolls — perfect for API-backed tables with paginated endpoints.
"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 { DataTableHeader, DataTableBody, DataTableEmptyBody, DataTableSkeleton, DataTableLoadingMore,} from "@/components/niko-table/core/data-table-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: The first thing a consumer does is copy this * example into their own app. If the generator used Math.random() at * module scope, every SSR/RSC-rendered cell would diff from its * client-rendered counterpart on first hydration (different prices, * stock counts, statuses on server vs client) and React would log a * hydration mismatch for every row. Index-based values sidestep that * entirely and are safe to run at module scope, inside useState * initializers, or under React Strict Mode double-invocation. * * When you swap this out for a real API call, the concern disappears * — real data is already stable. */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" },]
function 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> )}
// Total pool the server "knows about" — deterministic at module scope.const TOTAL_POOL = generateMockProducts(500)const PAGE_SIZE = 20const FAKE_LATENCY_MS = 800
/** * Simulate a paginated API call. Returns the next page of rows after * the given offset, with a short artificial delay so the loading * spinner is visibly rendered. */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 InfiniteScrollTableExample() { // Accumulated rows so far. Starts empty and grows as pages arrive. const [loaded, setLoaded] = React.useState<Product[]>([]) // True during the very first page fetch — drives the <DataTableSkeleton> row. const [isLoading, setIsLoading] = React.useState(true) // True during any subsequent next-page fetch — drives <DataTableLoadingMore>. const [isFetching, setIsFetching] = React.useState(false)
// Derive whether the server has more rows. When the accumulator // reaches the total pool size, we stop firing next-page requests. const hasMore = loaded.length < TOTAL_POOL.length
// Kick off the initial page fetch on mount. 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 () => { // Double-guard: never fire while a fetch is already in flight, // and never fire when we've exhausted the pool. 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={{ // High page size so client-side pagination doesn't clip // the infinite-scroll viewport. We still render the // pagination component below for filter/sort access, but // the scroll container is what drives row loading. initialPageSize: 500, enableExpanding: true, }} getRowCanExpand={row => row.original.stock > 0} > <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search products..." /> <DataTableFacetedFilter accessorKey="status" title="Status" options={statusOptions} /> <DataTableViewMenu /> </DataTableToolbarSection> {/* IMPORTANT: the `<DataTable>` MUST have a fixed height (`max-h-*` or `height`). Without it there's no scroll container at all, `onScrolledBottom` never fires, and infinite scroll silently does nothing. */} <DataTable className="max-h-[600px] rounded-lg border"> <DataTableHeader /> <DataTableBody scrollThreshold={200} onScrolledBottom={() => { // Fire-and-forget — the loadMore handler is itself // guarded against re-entry, so a trigger-happy // scroll callback can't double-fetch. if (hasMore && !isFetching) void loadMore() }} > <DataTableSkeleton rows={10} /> <DataTableEmptyBody> <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> </DataTableEmptyBody> {/* Composable "loading more" row — self-gates on `isFetching`. Sits alongside Skeleton + EmptyBody following niko-table's mix-and-match children pattern, so adding / removing the indicator is just adding / removing this child. */} <DataTableLoadingMore isFetching={isFetching}> Loading more products... </DataTableLoadingMore> </DataTableBody> </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": 500
}{}{
"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 { DataTableHeader, DataTableBody, DataTableEmptyBody, DataTableSkeleton, DataTableLoadingMore,} from "@/components/niko-table/core/data-table-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" },]
function 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(500)const PAGE_SIZE = 20const 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 InfiniteScrollTableStateExample() { // 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: 500, })
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: 500 }) }, [])
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={{ initialPageSize: 500, 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 className="max-h-[600px] rounded-lg border"> <DataTableHeader /> <DataTableBody scrollThreshold={200} onScrolledBottom={() => { if (hasMore && !isFetching) void loadMore() }} > <DataTableSkeleton rows={10} /> <DataTableEmptyBody> <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> </DataTableEmptyBody> <DataTableLoadingMore isFetching={isFetching}> Loading more products... </DataTableLoadingMore> </DataTableBody> </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 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 Table loads additional rows on demand as the user scrolls toward the bottom of the table — the standard UX for cursor-paginated APIs, activity feeds, and long product lists where loading everything upfront would be wasteful.
Niko Table ships the scroll trigger (onScrolledBottom on DataTableBody) and the composable “loading more” indicator (DataTableLoadingMore) as first-class primitives, so wiring infinite scroll is just: drop one child into the body, wire one callback, done.
This example uses a mocked in-memory API with artificial latency so you can see every state — initial skeleton, successive next-page spinner rows, and the end-of-results sentinel — without touching a real backend.
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 table that paginates through a 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”One important constraint when building demos: don’t use Math.random() or Date.now() in your mock data. If a consumer copies this file into a Next.js / Remix / RSC app, those non-deterministic calls produce different values on the server render vs the client hydration — and React will log a hydration mismatch on every row. Use index-based deterministic values instead:
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", } })}
// The "server" — a 500-item pool we paginate through.const TOTAL_POOL = generateMockProducts(500)const PAGE_SIZE = 20const FAKE_LATENCY_MS = 800
// Simulate a paginated API endpoint.function fetchNextPage(offset: number): Promise<Product[]> { return new Promise(resolve => { setTimeout(() => { resolve(TOTAL_POOL.slice(offset, offset + PAGE_SIZE)) }, FAKE_LATENCY_MS) })}When you swap this out for a real API call (TanStack Query’s useInfiniteQuery, SWR’s useSWRInfinite, tRPC, a plain fetch, etc.) the hydration concern disappears — real API data is already stable.
Pagination state + loadMore handler
Section titled “Pagination state + loadMore handler”The component tracks three pieces of state:
loaded— the accumulator of rows fetched so far.isLoading— true during the very first fetch (drivesDataTableSkeleton).isFetching— true during any subsequent next-page fetch (drivesDataTableLoadingMore).
const [loaded, setLoaded] = React.useState<Product[]>([])const [isLoading, setIsLoading] = React.useState(true)const [isFetching, setIsFetching] = React.useState(false)
// Derive "is there more to fetch?" from the accumulator size.const hasMore = loaded.length < TOTAL_POOL.length
// Kick off the first page on mount.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 () => { // Double-guard: never fire while a fetch is in flight, never // fire when the pool is exhausted. if (isFetching || !hasMore) return setIsFetching(true) try { const nextPage = await fetchNextPage(loaded.length) setLoaded(prev => [...prev, ...nextPage]) } finally { setIsFetching(false) }}, [isFetching, hasMore, loaded.length])<DataTableBody /> with onScrolledBottom + <DataTableLoadingMore />
Section titled “<DataTableBody /> with onScrolledBottom + <DataTableLoadingMore />”The magic happens here. DataTableBody fires onScrolledBottom when the user scrolls within scrollThreshold pixels of the bottom. DataTableLoadingMore is a composable child that self-gates on its own isFetching prop — render it unconditionally, and it appears/disappears as fetches come and go.
"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 { DataTableHeader, DataTableBody, DataTableEmptyBody, DataTableSkeleton, DataTableLoadingMore,} from "@/components/niko-table/core/data-table-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 InfiniteScrollTable() { // ... state + loadMore from the previous section ...
return ( <DataTableRoot data={loaded} columns={columns} isLoading={isLoading} config={{ initialPageSize: 500 }} > <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search products..." /> <DataTableViewMenu /> </DataTableToolbarSection>
<DataTable className="max-h-[600px] rounded-lg border"> <DataTableHeader /> <DataTableBody scrollThreshold={200} onScrolledBottom={() => { if (hasMore && !isFetching) void loadMore() }} > <DataTableSkeleton rows={10} /> <DataTableEmptyBody /> <DataTableLoadingMore isFetching={isFetching}> Loading more products... </DataTableLoadingMore> </DataTableBody> </DataTable> </DataTableRoot> )}Required: Fixed Height
Section titled “Required: Fixed Height”Infinite scroll needs a scroll container. Without a fixed max-h-* (or height) on <DataTable>, the table grows with its content and there’s no overflow to scroll — which means onScrolledBottom will never fire and your loadMore handler will silently do nothing.
<DataTable className="max-h-[600px] rounded-lg border"> {/* ... */}</DataTable>Guard Against Double-Fetches
Section titled “Guard Against Double-Fetches”Always guard your loadMore handler against re-entry. Scroll events can fire in rapid bursts (especially on momentum scroll on mobile), so even a well-placed onScrolledBottom can be triggered multiple times before React has a chance to flip isFetching to true.
onScrolledBottom={() => { if (hasMore && !isFetching) void loadMore()}}
const loadMore = React.useCallback(async () => { // Double-guard inside the handler itself. if (isFetching || !hasMore) return // ...}, [isFetching, hasMore])When to Use
Section titled “When to Use”✅ Use this non-virtualized variant when:
- Your total dataset is small (under ~200 rows) and virtualization is overkill
- You need variable row heights that don’t work well with
estimateSize - You want the simplest possible setup with no
@tanstack/react-virtualdependency
⚠️ For most infinite-scroll use cases, prefer the Infinite Scroll Virtualized Table instead. It renders only visible rows regardless of how many are loaded, so performance stays constant even after accumulating thousands of rows.
❌ Don’t use Infinite Scroll Table when:
- You need client-side global sorting or filtering across the entire dataset (you can’t sort what hasn’t been fetched yet — prefer server-side sort/filter, or fetch all rows upfront)
- Users need to bookmark or deep-link to a specific row (infinite scroll has no stable page URLs)
- The dataset is small enough to fit in one round-trip (just use Basic Table)
Next Steps
Section titled “Next Steps”- Virtualization Table — for large datasets already fully loaded
- Infinite Scroll Virtualized Table — combine both for massive paginated datasets
- Server-Side Table — when you need server-driven sort/filter with traditional pagination