Data Table
Build a DataTable with TanStack Table, shadcn, sorting, filtering, and pagination.
Recipe
Quick-reference recipe card — copy-paste ready.
npx shadcn@latest add table button input
npm install @tanstack/react-table"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((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{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>
);
}When to reach for this: When you need a feature-rich data table with sorting, filtering, pagination, or row selection.
Working Example
"use client";
import { useState } from "react";
import {
ColumnDef, flexRender, getCoreRowModel, getSortedRowModel,
getFilteredRowModel, getPaginationRowModel,
SortingState, ColumnFiltersState,
useReactTable,
} from "@tanstack/react-table";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowUpDown } from "lucide-react";
// Data type
type User = {
id: string;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
status: "active" | "inactive";
lastLogin: string;
};
// Sample data
const users: User[] = [
{ id: "1", name: "Alice Johnson", email: "alice@example.com", role: "admin", status: "active", lastLogin: "2025-12-01" },
{ id: "2", name: "Bob Smith", email: "bob@example.com", role: "editor", status: "active", lastLogin: "2025-11-28" },
{ id: "3", name: "Charlie Brown", email: "charlie@example.com", role: "viewer", status: "inactive", lastLogin: "2025-10-15" },
{ id: "4", name: "Diana Prince", email: "diana@example.com", role: "admin", status: "active", lastLogin: "2025-12-02" },
{ id: "5", name: "Eve Wilson", email: "eve@example.com", role: "editor", status: "inactive", lastLogin: "2025-09-20" },
{ id: "6", name: "Frank Castle", email: "frank@example.com", role: "viewer", status: "active", lastLogin: "2025-11-30" },
];
// Column definitions
const columns: ColumnDef<User>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Name <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "role",
header: "Role",
cell: ({ row }) => (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium capitalize">
{row.getValue("role")}
</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
status === "active" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
}`}>
{status}
</span>
);
},
},
{
accessorKey: "lastLogin",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Last Login <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
];
export function UsersTable() {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data: users,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
state: { sorting, columnFilters },
initialState: { pagination: { pageSize: 4 } },
});
return (
<div className="space-y-4">
{/* Filter */}
<Input
placeholder="Filter by name..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(e) => table.getColumn("name")?.setFilterValue(e.target.value)}
className="max-w-sm"
/>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{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>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</p>
<div className="flex gap-2">
<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>
</div>
</div>
);
}What this demonstrates:
- TanStack Table with shadcn Table components
- Column sorting with toggle button
- Text filtering on the name column
- Client-side pagination with page controls
- Custom cell renderers (badges for role and status)
- Empty state handling
Deep Dive
How It Works
useReactTableis the core hook — it takes data, columns, and feature models- Feature models (
getCoreRowModel,getSortedRowModel, etc.) are tree-shakeable plugins - Column definitions specify how to access data (
accessorKey), render headers, and render cells flexRenderrenders a column definition (which can be a string, component, or function)- State (sorting, filters, pagination) is managed externally via React state and passed to the table
- shadcn
Tablecomponents are thin wrappers around<table>,<thead>,<tr>,<td>with consistent styling
Variations
Row selection with checkboxes:
import { Checkbox } from "@/components/ui/checkbox";
const selectColumn: ColumnDef<User> = {
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
/>
),
};
// Add to columns array
const columns = [selectColumn, ...otherColumns];
// Enable row selection in useReactTable
const table = useReactTable({
...config,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
state: { rowSelection },
});Server-side pagination:
const table = useReactTable({
data: serverData.rows,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: serverData.totalPages,
onPaginationChange: (updater) => {
const next = typeof updater === "function"
? updater(pagination)
: updater;
setPagination(next);
fetchPage(next.pageIndex, next.pageSize);
},
state: { pagination },
});Row actions dropdown:
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal } from "lucide-react";
const actionsColumn: ColumnDef<User> = {
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editUser(row.original)}>Edit</DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => deleteUser(row.original.id)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};TypeScript Notes
// ColumnDef is generic
const columns: ColumnDef<User>[] = [
{ accessorKey: "name", header: "Name" }, // accessorKey is typed against User
];
// row.original is fully typed
cell: ({ row }) => {
const user: User = row.original;
return <span>{user.email}</span>;
};
// row.getValue is typed by column
row.getValue("status"); // typed by accessorKey
// Generic DataTable component
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}Gotchas
-
Column definitions must be stable — Defining columns inside the component causes infinite re-renders. Fix: Define columns outside the component or wrap in
useMemo. -
accessorKeyvsaccessorFn—accessorKeyis for simple property access;accessorFnis for computed or nested values. Mixing them up causes type issues. -
Server-side pagination resets on filter — When filtering changes, you need to reset to page 0. Fix: Reset
pageIndexwhen filters change. -
Large datasets — Client-side sorting/filtering with 10,000+ rows can lag. Fix: Use server-side pagination, sorting, and filtering with
manual*options. -
Sticky headers — shadcn Table does not include sticky headers by default. Fix: Add
className="sticky top-0 bg-white z-10"toTableHeader.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Plain <table> | Displaying small, static data without interactivity | You need sorting, filtering, or pagination |
| AG Grid | You need Excel-like features (cell editing, pivot, charts) | A simpler table suffices |
| React Virtuoso | You have thousands of rows and need virtualization | Paginated data fits your UX |
| Tremor Table | You use the Tremor dashboard library | You use shadcn/ui |
FAQs
What are the core packages needed to build a DataTable with shadcn?
@tanstack/react-tablefor table logic (sorting, filtering, pagination)- shadcn
table,button, andinputcomponents for the UI - Install with
npx shadcn@latest add table button inputandnpm install @tanstack/react-table
What is flexRender and why is it needed?
flexRenderrenders a column definition which can be a string, React component, or function- It bridges TanStack Table's column config with actual JSX output
- Used for both header and cell rendering
How do you add sorting to a column?
header: ({ column }) => (
<Button variant="ghost" onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}>
Name <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),Also add getSortedRowModel: getSortedRowModel() and onSortingChange: setSorting to useReactTable.
Gotcha: Why do column definitions defined inside the component cause infinite re-renders?
- React creates a new array reference on every render
- TanStack Table detects the new reference and re-processes columns, triggering another render
- Fix: define columns outside the component or wrap in
useMemo
How do you implement server-side pagination instead of client-side?
- Set
manualPagination: trueandpageCount: serverData.totalPages - Use
onPaginationChangeto fetch the next page from the server - Do not include
getPaginationRowModel()since the server handles slicing
How do you add row selection with checkboxes?
const selectColumn: ColumnDef<User> = {
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
/>
),
};What is the difference between accessorKey and accessorFn?
accessorKeyis for simple, direct property access (e.g.,"name"maps torow.name)accessorFnis for computed or nested values (e.g.,(row) => row.address.city)- Mixing them up causes TypeScript type issues
How is row.original typed in TanStack Table?
row.originalis fully typed as your data type (e.g.,User)- The generic
ColumnDef<User>propagates through sorow.original.emailis type-safe
Gotcha: Why does filtering break when you change pages but don't reset to page 0?
- Server-side pagination does not automatically reset
pageIndexwhen filters change - You may end up requesting a page that no longer exists
- Fix: reset
pageIndexto 0 whenever filters change
How do you add a row actions dropdown to each row?
const actionsColumn: ColumnDef<User> = {
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editUser(row.original)}>Edit</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};How do you type a generic reusable DataTable component?
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns, data
}: DataTableProps<TData, TValue>) { ... }How do you make table headers sticky?
- shadcn Table does not include sticky headers by default
- Add
className="sticky top-0 bg-white z-10"toTableHeader - Ensure the table container has a fixed height with
overflow-y: auto