Skip to content

Virtualization Table

Handle large datasets efficiently with virtual scrolling.

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,
} 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 { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"
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 { ChevronRight, ChevronDown, UserSearch, 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
}
// Generate large dataset for virtualization demo
const generateLargeData = (count: number): Product[] => {
const categories = [
"Electronics",
"Clothing",
"Food",
"Books",
"Sports",
"Home",
"Toys",
"Beauty",
]
const brands = [
"Apple",
"Samsung",
"Nike",
"Adidas",
"Sony",
"LG",
"Dell",
"HP",
]
return Array.from({ length: count }, (_, i) => {
const stock = Math.floor(Math.random() * 150)
const price = Math.floor(Math.random() * 500) + 10
return {
id: `product-${i + 1}`,
name: `Product ${i + 1}`,
category: categories[Math.floor(Math.random() * categories.length)],
brand: brands[Math.floor(Math.random() * brands.length)],
price,
stock,
rating: Math.floor(Math.random() * 5) + 1,
revenue: price * stock,
status:
stock === 0 ? "out-of-stock" : stock < 20 ? "low-stock" : "in-stock",
releaseDate: new Date(
2024,
Math.floor(Math.random() * 12),
Math.floor(Math.random() * 28) + 1,
),
}
})
}
const largeData = generateLargeData(10000) // 10,000 items
// 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>
)
}
export default function VirtualizedTableExample() {
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={largeData}
columns={columns}
config={{
enableExpanding: true,
initialPageSize: 50,
}}
getRowCanExpand={row => row.original.stock > 0}
>
<DataTableToolbarSection>
<DataTableSearchFilter placeholder="Search products..." />
<DataTableViewMenu />
</DataTableToolbarSection>
<DataTable height={600} className="rounded-lg border">
<DataTableVirtualizedHeader />
<DataTableVirtualizedBody>
<DataTableVirtualizedEmptyBody>
<DataTableEmptyMessage>
<DataTableEmptyIcon>
<UserSearch 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>
</DataTableVirtualizedBody>
</DataTable>
<DataTablePagination pageSizeOptions={[50, 100, 200, 500]} />
</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,
} from "@/components/niko-table/core/data-table-virtualized-structure"
import { DataTableColumnActions } from "@/components/niko-table/components/data-table-column-actions"
import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"
import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"
import {
DataTableColumnSortMenu,
DataTableColumnSortOptions,
} 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 { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"
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 { ChevronRight, ChevronDown, UserSearch, SearchX } from "lucide-react"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
// 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
}
// Generate large dataset for virtualization demo
const generateLargeData = (count: number): Product[] => {
const categories = [
"Electronics",
"Clothing",
"Food",
"Books",
"Sports",
"Home",
"Toys",
"Beauty",
]
const brands = [
"Apple",
"Samsung",
"Nike",
"Adidas",
"Sony",
"LG",
"Dell",
"HP",
]
return Array.from({ length: count }, (_, i) => {
const stock = Math.floor(Math.random() * 150)
const price = Math.floor(Math.random() * 500) + 10
return {
id: `product-${i + 1}`,
name: `Product ${i + 1}`,
category: categories[Math.floor(Math.random() * categories.length)],
brand: brands[Math.floor(Math.random() * brands.length)],
price,
stock,
rating: Math.floor(Math.random() * 5) + 1,
revenue: price * stock,
status:
stock === 0 ? "out-of-stock" : stock < 20 ? "low-stock" : "in-stock",
releaseDate: new Date(
2024,
Math.floor(Math.random() * 12),
Math.floor(Math.random() * 28) + 1,
),
}
})
}
const largeData = generateLargeData(10000) // 10,000 items
// 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>
)
}
export default function VirtualizedTableStateExample() {
// Controlled state management for all table state
const [data] = useState<Product[]>(largeData)
const [globalFilter, setGlobalFilter] = useState<string | object>("")
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100, // Larger page size for virtualization
})
const [expanded, setExpanded] = useState<ExpandedState>({})
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
left: [],
right: [],
})
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 className="justify-start">
<span className="mr-2 text-sm font-semibold">Name</span>
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
cell: ({ row }) => (
<div className="font-medium">{row.getValue("name")}</div>
),
},
{
accessorKey: "category",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />
<DataTableColumnActions>
<DataTableColumnSortOptions variant={FILTER_VARIANTS.TEXT} />
</DataTableColumnActions>
</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>
},
},
],
[],
)
const resetAllState = () => {
setGlobalFilter("")
setSorting([])
setColumnFilters([])
setColumnVisibility({})
setPagination({ pageIndex: 0, pageSize: 100 })
setExpanded({})
setColumnPinning({ left: [], right: [] })
}
// Calculate filtered data for display metrics
const filteredRowCount = React.useMemo(() => {
let filtered = data
// Apply global filter
if (globalFilter && typeof globalFilter === "string") {
filtered = filtered.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(globalFilter.toLowerCase()),
),
)
}
return filtered.length
}, [data, globalFilter])
return (
<div className="w-full space-y-4">
<DataTableRoot
data={data}
columns={columns}
config={{
enableExpanding: true,
}}
getRowCanExpand={row => row.original.stock > 0}
// Controlled state
state={{
globalFilter,
sorting,
columnFilters,
columnVisibility,
pagination,
expanded,
columnPinning,
}}
// State updaters
onGlobalFilterChange={value => {
setGlobalFilter(value)
setPagination(prev => ({ ...prev, pageIndex: 0 }))
}}
onSortingChange={setSorting}
onColumnFiltersChange={filters => {
setColumnFilters(filters)
setPagination(prev => ({ ...prev, pageIndex: 0 }))
}}
onColumnVisibilityChange={setColumnVisibility}
onPaginationChange={setPagination}
onExpandedChange={setExpanded}
onColumnPinningChange={setColumnPinning}
>
<DataTableToolbarSection>
<DataTableSearchFilter placeholder="Search products..." />
<DataTableViewMenu />
</DataTableToolbarSection>
<DataTable height={600} className="rounded-lg border">
<DataTableVirtualizedHeader />
<DataTableVirtualizedBody>
<DataTableVirtualizedEmptyBody>
<DataTableEmptyMessage>
<DataTableEmptyIcon>
<UserSearch 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>
</DataTableVirtualizedBody>
</DataTable>
<DataTablePagination pageSizeOptions={[50, 100, 200, 500]} />
</DataTableRoot>
{/* State Display for demonstration */}
<Card>
<CardHeader>
<CardTitle>Virtualized Table State</CardTitle>
<CardDescription>
Live view of the virtualized table state with{" "}
{data.length.toLocaleString()} total items
</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">
{data.length.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Filtered Items:</span>
<span className="text-foreground">
{filteredRowCount.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Active Filters:</span>
<span className="text-foreground">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 Virtualization Table uses virtual scrolling to efficiently render large datasets (1000+ rows) by only rendering visible rows. This provides smooth performance even with 10,000+ rows.

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-pagination @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.

For other add-ons or manual copy-paste, see the Installation Guide.

We are going to build a table to show products with virtualization. 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"
}
// Generate large dataset
const largeData: Product[] = Array.from({ length: 10000 }, (_, i) => ({
id: `product-${i + 1}`,
name: `Product ${i + 1}`,
category: "Electronics",
price: Math.floor(Math.random() * 500) + 10,
stock: Math.floor(Math.random() * 150),
status: "in-stock",
}))

Let’s start by building a virtualized table.

First, we’ll define our columns.

columns.tsx
"use client"
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 type { DataTableColumnDef } from "@/components/niko-table/types"
export type Product = {
id: string
name: string
category: string
price: number
stock: number
status: "in-stock" | "low-stock" | "out-of-stock"
}
export const columns: DataTableColumnDef<Product>[] = [
{
accessorKey: "name",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Name" },
cell: ({ row }) => (
<div className="font-medium">{row.getValue("name")}</div>
),
},
{
accessorKey: "category",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Category" },
},
{
accessorKey: "price",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Price" },
cell: ({ row }) => {
const price = row.getValue("price") as number
return <div className="font-mono">${price.toFixed(2)}</div>
},
},
{
accessorKey: "stock",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Stock" },
},
{
accessorKey: "status",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Status" },
},
]

Next, we’ll create a virtualized table.

virtualization-table.tsx
"use client"
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"
import { DataTable } from "@/components/niko-table/core/data-table"
import {
DataTableVirtualizedHeader,
DataTableVirtualizedBody,
DataTableVirtualizedEmptyBody,
DataTableVirtualizedSkeleton,
} from "@/components/niko-table/core/data-table-virtualized-structure"
import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"
import { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"
import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"
import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"
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 type { DataTableColumnDef } from "@/components/niko-table/types"
type Product = {
id: string
name: string
category: string
price: number
stock: number
status: "in-stock" | "low-stock" | "out-of-stock"
}
const columns: DataTableColumnDef<Product>[] = [
{
accessorKey: "name",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Name" },
cell: ({ row }) => (
<div className="font-medium">{row.getValue("name")}</div>
),
},
{
accessorKey: "category",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Category" },
},
{
accessorKey: "price",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Price" },
cell: ({ row }) => {
const price = row.getValue("price") as number
return <div className="font-mono">${price.toFixed(2)}</div>
},
},
{
accessorKey: "stock",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Stock" },
},
{
accessorKey: "status",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
meta: { label: "Status" },
},
]
export function VirtualizationTable({ data }: { data: Product[] }) {
return (
<DataTableRoot
data={data}
columns={columns}
config={{
enablePagination: true,
enableSorting: true,
enableFilters: true,
}}
>
<DataTableToolbarSection>
<DataTableSearchFilter placeholder="Search products..." />
<DataTableViewMenu />
</DataTableToolbarSection>
<DataTable height={600} className="rounded-lg border">
<DataTableVirtualizedHeader />
<DataTableVirtualizedBody>
<DataTableVirtualizedSkeleton rows={5} />
<DataTableVirtualizedEmptyBody />
</DataTableVirtualizedBody>
</DataTable>
<DataTablePagination pageSizeOptions={[50, 100, 200, 500]} />
</DataTableRoot>
)
}

Virtualized version of the table header.

<DataTableVirtualizedHeader />

Virtualized version of the table body. Only renders visible rows.

<DataTableVirtualizedBody>
<DataTableVirtualizedEmptyBody />
</DataTableVirtualizedBody>

Empty state component for virtualized tables.

<DataTableVirtualizedEmptyBody />

Skeleton loading state for virtualized tables. Shows skeleton rows during data fetching.

<DataTableVirtualizedBody>
<DataTableVirtualizedSkeleton rows={5} />
<DataTableVirtualizedEmptyBody />
</DataTableVirtualizedBody>

Simple loading spinner for virtualized tables. Shows a centered loading indicator.

<DataTableVirtualizedBody>
<DataTableVirtualizedLoading />
<DataTableVirtualizedEmptyBody />
</DataTableVirtualizedBody>

Note: Use DataTableVirtualizedSkeleton for a more detailed loading state that mimics the table structure, or DataTableVirtualizedLoading for a simple spinner.

Virtualization requires a fixed height on the DataTable component:

<DataTable height={600} className="rounded-lg border">
<DataTableVirtualizedHeader />
<DataTableVirtualizedBody>
<DataTableVirtualizedSkeleton rows={5} />
<DataTableVirtualizedEmptyBody />
</DataTableVirtualizedBody>
</DataTable>

Manage table state externally for full control:

import { useState } from "react"
import type {
SortingState,
ColumnFiltersState,
PaginationState,
} from "@tanstack/react-table"
export function ControlledVirtualizationTable({ data }: { data: Product[] }) {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
})
const [globalFilter, setGlobalFilter] = useState("")
return (
<DataTableRoot
data={data}
columns={columns}
config={{
enablePagination: true,
enableSorting: true,
enableFilters: true,
}}
state={{
sorting,
columnFilters,
pagination,
globalFilter,
}}
onSortingChange={setSorting}
onColumnFiltersChange={setColumnFilters}
onPaginationChange={setPagination}
onGlobalFilterChange={setGlobalFilter}
>
<DataTableToolbarSection>
<DataTableSearchFilter placeholder="Search products..." />
<DataTableViewMenu />
</DataTableToolbarSection>
<DataTable height={600} className="rounded-lg border">
<DataTableVirtualizedHeader />
<DataTableVirtualizedBody>
<DataTableVirtualizedSkeleton rows={5} />
<DataTableVirtualizedEmptyBody />
</DataTableVirtualizedBody>
</DataTable>
<DataTablePagination pageSizeOptions={[50, 100, 200, 500]} />
</DataTableRoot>
)
}

✅ Use Virtualization Table when:

  • You have 1000+ rows
  • Performance is critical
  • Users need smooth scrolling
  • Dataset is too large for regular pagination

❌ Don’t use Virtualization Table when:

  • You have < 1000 rows (use Basic Table)
  • You need variable row heights (virtualization works best with fixed heights)
  • You need complex row interactions (virtualization has limitations)
  1. Fixed height required: Always set a fixed height on DataTable
  2. Consistent row heights: Virtualization works best with uniform row heights
  3. Memoize columns: Wrap column definitions in useMemo
  4. Large page sizes: Use larger page sizes (50-500) for better virtualization