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
"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 type
interface 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 details
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>
)
}
// Larger pool so virtualization value is actually demonstrated —
// 5,000 rows, 50 pages of 100 each.
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)
})
}
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&apos;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
Open in
"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 type
interface 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 details
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(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)
})
}
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&apos;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>
)
}

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