shadcn ui : DataTable Recipe (TanStack Table v8)
Deterministic mental model for the shadcn DataTable. There is NO DataTable primitive in shadcn. The official recipe (https://ui.shadcn.com/docs/components/radix/data-table) composes @tanstack/react-table v8 with the shadcn Table primitive. ALWAYS treat DataTable as a recipe-skill, not a primitive-skill : you write a reusable components/ui/data-table.tsx file in user space.
Quick Reference
One mental model
A DataTable is three concerns wired together :
- Schema : a
ColumnDef<TData, TValue>[]array describes columns (accessor + header + cell). - Headless engine :
useReactTable({ data, columns, getCoreRowModel, ... })returns atableinstance. - Markup : a
<DataTable />component renders the shadcn<Table>primitive driven bytable.getHeaderGroups()andtable.getRowModel().rows.
ALWAYS pass getCoreRowModel: getCoreRowModel() or the table renders ZERO rows. ALWAYS mark the DataTable component file "use client" ; the headless engine uses React hooks and breaks under server-rendering.
Install
npx shadcn@latest add table
npm install @tanstack/react-table
ALWAYS install the table primitive first ; the recipe imports Table, TableBody, TableCell, TableHead, TableHeader, TableRow from @/components/ui/table.
Six features, six row-model functions
| Feature | Row model fn | State key | Setter prop |
|---|---|---|---|
| Render rows | getCoreRowModel() |
: | : |
| Sort | getSortedRowModel() |
sorting: SortingState |
onSortingChange |
| Filter | getFilteredRowModel() |
columnFilters: ColumnFiltersState |
onColumnFiltersChange |
| Paginate | getPaginationRowModel() |
pagination: PaginationState |
onPaginationChange |
| Select rows | getCoreRowModel() (no extra fn) |
rowSelection: RowSelectionState |
onRowSelectionChange |
| Toggle columns | getCoreRowModel() (no extra fn) |
columnVisibility: VisibilityState |
onColumnVisibilityChange |
Client vs server data : the single most important decision
| Question | Answer |
|---|---|
| Dataset under ~1000 rows AND loaded once ? | Client-side. Pass full array to data. NO manualX flags. |
| Dataset over 1000 rows OR loaded per page ? | Server-side. Set manualSorting: true, manualFiltering: true, manualPagination: true. Provide pageCount (or rowCount). Refetch in a useEffect on state change. |
| Mixed (server pagination, client sort) ? | RARE. Only enable the manualX flag for what you actually compute server-side. Mismatch causes sorting to fire twice. |
NEVER mix client-side and server-side for the SAME feature. If manualSorting: true is set, the engine STOPS sorting client-side ; you MUST refetch sorted data from the server.
'use client' boundary
ALWAYS put "use client" at the top of components/ui/data-table.tsx. The TanStack hook + the state hooks (sorting, filters, etc.) need a client component. The page that USES <DataTable /> may stay a server component as long as it imports <DataTable /> from a client module.
ColumnDef Pattern
Minimal accessor-based column
import type { ColumnDef } from "@tanstack/react-table"
export type Payment = {
id: string
amount: number
status: "pending" | "processing" | "success" | "failed"
email: string
}
export const columns: ColumnDef<Payment>[] = [
{ accessorKey: "status", header: "Status" },
{ accessorKey: "email", header: "Email" },
{ accessorKey: "amount", header: "Amount" },
]
ALWAYS provide either accessorKey (string key into TData) OR accessorFn (function returning the cell value). Without one, the cell renders undefined.
Custom cell renderer with flexRender
import { flexRender } from "@tanstack/react-table"
export const columns: ColumnDef<Payment>[] = [
{
accessorKey: "amount",
header: "Amount",
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>
},
},
]
ALWAYS use flexRender(cell.column.columnDef.cell, cell.getContext()) in the body markup so dynamic cells render correctly. NEVER read cell.value directly.
Sortable header
{
accessorKey: "email",
header: ({ column }) => (
<Button variant="ghost" => column.toggleSorting(column.getIsSorted() === "asc")}>
Email <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
}
useReactTable Setup
Canonical six-feature client-side composition
"use client"
import * as React from "react"
import {
ColumnDef, ColumnFiltersState, SortingState, VisibilityState,
flexRender,
getCoreRowModel, getFilteredRowModel, getPaginationRowModel,
getSortedRowModel, useReactTable,
} from "@tanstack/react-table"
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: { sorting, columnFilters, columnVisibility, rowSelection },
})
ALWAYS pair every onXChange setter with its state.X value, or the table loses controlled state.
Feature Decision Matrix
| You need | Add to useReactTable | Optional state | Why |
|---|---|---|---|
| Just render rows | getCoreRowModel: getCoreRowModel() |
: | Without it, table.getRowModel().rows is empty. |
| Click header to sort | getSortedRowModel: getSortedRowModel() + onSortingChange + state.sorting |
: | Without controlled state, sorting still works but is uncontrolled and unreadable from outside. |
| Per-column text filter | getFilteredRowModel: getFilteredRowModel() + onColumnFiltersChange + state.columnFilters |
: | Drive an <Input> that calls table.getColumn("email")?.setFilterValue(value). |
| Pagination | getPaginationRowModel: getPaginationRowModel() |
initialState.pagination = { pageSize: 10 } |
Without it, table.nextPage()/previousPage() no-op. |
| Row selection (checkboxes) | onRowSelectionChange + state.rowSelection |
enableRowSelection: true, getRowId: (row) => row.id |
NEVER omit getRowId if rows can be reordered/refetched ; selection collides on array-index keys. |
| Column visibility toggle | onColumnVisibilityChange + state.columnVisibility |
: | Drive a <DropdownMenu> listing table.getAllColumns().filter(c => c.getCanHide()). |
| Column resizing | columnResizeMode: "onChange", enableColumnResizing: true |
per-column enableResizing: false |
OPTIONAL. Requires inline styles on <TableHead> with style={{ width: header.getSize() }}. |
Server-Side Data Contract
Server-side mode tells TanStack Table "I will sort, filter, and paginate ; you just render what I give you and surface the state."
Required wiring
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 10 })
const { data, pageCount } = useQuery({ /* refetches on every state change */ })
const table = useReactTable({
data: data ?? [],
columns,
pageCount, // total page count from server
state: { sorting, columnFilters, pagination },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onPaginationChange: setPagination,
manualSorting: true,
manualFiltering: true,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
})
ALWAYS provide pageCount (or rowCount) when manualPagination: true. Without it, the engine defaults pageCount to -1 and "next page" is infinite.
ALWAYS refetch on sorting, columnFilters, and pagination change. Use useQuery from TanStack Query, useEffect, or any data-fetching hook that takes these as dependencies.
NEVER add getSortedRowModel(), getFilteredRowModel(), or getPaginationRowModel() when their manualX flag is true ; the engine will sort/filter/paginate twice (once in your fetch, again in the model) and the visible order will not match what was fetched.
Client vs server decision tree
Is the full dataset already in memory ?
|--- YES -> client-side, add the get*RowModel for each feature.
|--- NO -> server-side, set manualX flags, provide pageCount,
refetch on state change.
DataTable Component Composition
ALWAYS extract the composition into components/ui/data-table.tsx for reuse. The component is generic over <TData, TValue>.
"use client"
import {
ColumnDef, flexRender,
getCoreRowModel, useReactTable,
} from "@tanstack/react-table"
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(header => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</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}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
)) : (
<TableRow><TableCell colSpan={columns.length} className="h-24 text-center">No results.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
)
}
ALWAYS render the empty-state row (No results.) so the table never collapses to zero height. ALWAYS spread additional state and setters as props if the parent owns sorting/filtering/selection.
Reference Links
- shadcn DataTable recipe : https://ui.shadcn.com/docs/components/radix/data-table
- TanStack Table v8 docs : https://tanstack.com/table/latest/docs
- TanStack pagination guide : https://tanstack.com/table/latest/docs/guide/pagination
- TanStack sorting guide : https://tanstack.com/table/latest/docs/guide/sorting
- TanStack row-selection guide : https://tanstack.com/table/latest/docs/guide/row-selection
Companion Skills
shadcn-syntax-table: the primitive<Table>/<TableHeader>/<TableBody>HTML wrapper this recipe renders into. READ FIRST if you are unsure of the primitive markup.shadcn-syntax-selectors: the controlled<Select>used for page-size dropdowns inside DataTable pagination footers.shadcn-syntax-menu-primitives: the<DropdownMenu>used to drive the column-visibility toggle.
Detailed References
references/methods.md: full ColumnDef, useReactTable, and per-feature signatures.references/examples.md: six worked recipes (minimal sortable, filter + paginate, row selection + bulk actions, server-side with Next.js, column visibility toggle, custom cell renderers).references/anti-patterns.md: six concrete failure modes with fixes.