Data Table
Powerful tables and datagrids built with TanStack Table on top of the Table component.
Last updated January 1, 1980
Copied from shadcn/ui and TanStack Table.
Every data table or datagrid is unique: they behave differently, have specific sorting and filtering requirements, and work with different data sources. Rather than one rigid component, this guide shows how to build your own on top of the headless @tanstack/react-table library and our <Table /> component.
Overview
Installation
Add the Table component to your project
Add the TanStack Table dependency. This guide uses v9:
Prerequisites
We're going to build a table showing recent payments:
type Payment = {
id: string
amount: number
status: "pending" | "processing" | "success" | "failed"
email: string
}
export const payments: Payment[] = [
{
id: "728ed52f",
amount: 100,
status: "pending",
email: "m@example.com",
},
{
id: "489e1d42",
amount: 125,
status: "processing",
email: "example@gmail.com",
},
// ...
]Set up Table Features
TanStack Table v9 is feature-based: you opt into behavior — sorting, filtering, pagination — by declaring it with tableFeatures(). Anything you don't list is tree-shaken out of your bundle.
import {
columnFilteringFeature,
columnVisibilityFeature,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_text,
tableFeatures,
} from "@tanstack/react-table"
// TanStack Table v9 is feature-based: declare the features this table uses —
// anything you don't register is tree-shaken out of the bundle.
export const features = tableFeatures({
columnFilteringFeature,
columnVisibilityFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(),
filterFns: { includesString: filterFn_includesString },
sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
})
// Pass this as the first generic argument to ColumnDef, Column, Table, and
// Row so each type knows which feature APIs are available.
export type DataTableFeatures = typeof featuresThe core row model is always included. Row models for optional features are created with create*RowModel() and registered on the features object — there are no more get*RowModel table options.
Basic Table
Define your columns
"use client"
import { createColumnHelper } from "@tanstack/react-table"
import { type DataTableFeatures } from "./data-table-features"
// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
export type Payment = {
id: string
amount: number
status: "pending" | "processing" | "success" | "failed"
email: string
}
// Use accessor for data columns and display for columns without one.
const columnHelper = createColumnHelper<DataTableFeatures, Payment>()
export const columns = columnHelper.columns([
columnHelper.accessor("status", {
header: "Status",
}),
columnHelper.accessor("email", {
header: "Email",
}),
columnHelper.accessor("amount", {
header: "Amount",
}),
])Create the DataTable component
"use client"
import { useTable } from "@tanstack/react-table"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { features, type DataTableFeatures } from "./data-table-features"
import { columns, type Payment } from "./columns"
interface DataTableProps<TData extends RowData> {
columns: ColumnDef<DataTableFeatures, TData>[]
data: TData[]
}
export function DataTable<TData extends RowData>({
columns,
data,
}: DataTableProps<TData>) {
const table = useTable({
features,
data,
columns,
})
return (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : (
<table.FlexRender header={header} />
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<table.FlexRender cell={cell} />
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
}Render the table in your page
import { columns, Payment } from "./columns"
import { DataTable } from "./data-table"
async function getData(): Promise<Payment[]> {
// Fetch data from your API here.
return [
{
id: "728ed52f",
amount: 100,
status: "pending",
email: "m@example.com",
},
// ...
]
}
export default async function DemoPage() {
const data = await getData()
return (
<div className="container mx-auto py-10">
<DataTable columns={columns} data={data} />
</div>
)
}Cell Formatting
Format the amount cell to display a dollar amount, aligned right:
export const columns = columnHelper.columns([
columnHelper.accessor("amount", {
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"))
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount)
return <div className="text-right font-medium">{formatted}</div>
},
}),
])Row Actions
Access row data with row.original inside a display column to drive actions like copying an ID or calling your API:
columnHelper.display({
id: "actions",
cell: ({ row }) => {
const payment = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" className="size-8 p-0" />}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(payment.id)}
>
Copy payment ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>View customer</DropdownMenuItem>
<DropdownMenuItem>View payment details</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
})Pagination
With rowPaginationFeature registered, rows paginate into pages of 10 automatically — wire up controls with table.previousPage() and table.nextPage():
<div className="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>Sorting
Wire up the sorting state, then make any header sortable by toggling it:
"use client"
import * as React from "react"
import { useTable, type SortingState } from "@tanstack/react-table"
export function DataTable<TData extends RowData>({
columns,
data,
}: DataTableProps<TData>) {
const [sorting, setSorting] = React.useState<SortingState>([])
const table = useTable({
features,
data,
columns,
onSortingChange: setSorting,
state: {
sorting,
},
})
// ...
}columnHelper.accessor("email", {
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Email
<ArrowUpDown className="size-4" />
</Button>
)
},
})Filtering
Add a search input that filters the email column through the registered includesString filter function:
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const table = useTable({
features,
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
state: {
sorting,
columnFilters,
},
})
return (
<Input
placeholder="Filter emails..."
value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("email")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
)Visibility
Toggle columns via the visibility state and a dropdown of checkboxes:
const [columnVisibility, setColumnVisibility] =
React.useState<ColumnVisibilityState>({})
const table = useTable({
features,
data,
columns,
onColumnVisibilityChange: setColumnVisibility,
state: {
columnVisibility,
},
})
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" className="ms-auto" />}>
Columns
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>Row Selection
Add a checkbox column with a select-all header, then read the selection from the filtered row model:
columnHelper.display({
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={
table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
})const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const table = useTable({
features,
data,
columns,
onRowSelectionChange: setRowSelection,
state: {
rowSelection,
},
})<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>RTL
The Table component is built entirely on logical CSS properties, so data tables mirror automatically under dir="rtl" — headers, cells, and action menus included. Format amounts with a locale-aware formatter such as fa-IR to show Toman values with Persian digits.