TanStack Table provides headless table logic with complete control over rendering. Here is how to build a full-featured data table.
Step 1: Install Dependencies
pnpm add @tanstack/react-table
Step 2: Define Columns
// components/data-table/columns.tsx
"use client";
import { ColumnDef } from "@tanstack/react-table";
export interface Payment {
id: string;
amount: number;
status: "pending" | "processing" | "success" | "failed";
email: string;
createdAt: string;
}
export const columns: ColumnDef<Payment>[] = [
{
id: "select",
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => <span className="lowercase">{row.getValue("email")}</span>,
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
const colors: Record<string, string> = {
pending: "bg-yellow-100 text-yellow-800",
processing: "bg-blue-100 text-blue-800",
success: "bg-green-100 text-green-800",
failed: "bg-red-100 text-red-800",
};
return (
<span className={`rounded-full px-2 py-1 text-xs font-medium ${colors[status]}`}>
{status}
</span>
);
},
filterFn: (row, id, value: string[]) => value.includes(row.getValue(id)),
},
{
accessorKey: "amount",
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"));
return (
<div className="text-right font-medium">
${(amount / 100).toFixed(2)}
</div>
);
},
},
{
accessorKey: "createdAt",
header: "Date",
cell: ({ row }) =>
new Date(row.getValue("createdAt")).toLocaleDateString(),
},
];
Step 3: Data Table Component
// components/data-table/DataTable.tsx
"use client";
import { useState } from "react";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
flexRender,
type SortingState,
type ColumnFiltersState,
type VisibilityState,
type RowSelectionState,
type ColumnDef,
} from "@tanstack/react-table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [globalFilter, setGlobalFilter] = useState("");
const table = useReactTable({
data,
columns,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
enableRowSelection: true,
});
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-4">
<input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Search all columns..."
className="max-w-sm rounded-lg border px-3 py-2 text-sm"
/>
<StatusFilter table={table} />
<ColumnToggle table={table} />
</div>
{/* Table */}
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="border-b bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left font-medium text-gray-500"
>
{header.isPlaceholder ? null : (
<button
className={`flex items-center gap-1 ${
header.column.getCanSort() ? "cursor-pointer select-none" : ""
}`}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getIsSorted() === "asc" && " ^"}
{header.column.getIsSorted() === "desc" && " v"}
</button>
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y">
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={row.getIsSelected() ? "bg-blue-50" : "hover:bg-gray-50"}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
) : (
<tr>
<td
colSpan={columns.length}
className="px-4 py-8 text-center text-gray-500"
>
No results found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
</p>
<div className="flex items-center gap-2">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="rounded-lg border px-3 py-1.5 text-sm disabled:opacity-50"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="rounded-lg border px-3 py-1.5 text-sm disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
);
}
Step 4: Status Filter
// components/data-table/StatusFilter.tsx
"use client";
import { Table } from "@tanstack/react-table";
const statuses = [
{ value: "pending", label: "Pending" },
{ value: "processing", label: "Processing" },
{ value: "success", label: "Success" },
{ value: "failed", label: "Failed" },
];
export function StatusFilter<TData>({ table }: { table: Table<TData> }) {
const column = table.getColumn("status");
const selectedValues = (column?.getFilterValue() as string[]) ?? [];
function toggleStatus(value: string) {
const newValues = selectedValues.includes(value)
? selectedValues.filter((v) => v !== value)
: [...selectedValues, value];
column?.setFilterValue(newValues.length > 0 ? newValues : undefined);
}
return (
<div className="flex gap-1">
{statuses.map((status) => (
<button
key={status.value}
onClick={() => toggleStatus(status.value)}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
selectedValues.includes(status.value)
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{status.label}
</button>
))}
</div>
);
}
Step 5: Column Visibility Toggle
// components/data-table/ColumnToggle.tsx
"use client";
import { useState } from "react";
import { Table } from "@tanstack/react-table";
export function ColumnToggle<TData>({ table }: { table: Table<TData> }) {
const [open, setOpen] = useState(false);
return (
<div className="relative ml-auto">
<button
onClick={() => setOpen(!open)}
className="rounded-lg border px-3 py-1.5 text-sm"
>
Columns
</button>
{open && (
<div className="absolute right-0 top-full z-10 mt-1 w-48 rounded-lg border bg-white p-2 shadow-lg">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<label
key={column.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50"
>
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
/>
{column.id}
</label>
))}
</div>
)}
</div>
);
}
Step 6: Usage
// app/payments/page.tsx
import { DataTable } from "@/components/data-table/DataTable";
import { columns, Payment } from "@/components/data-table/columns";
async function getPayments(): Promise<Payment[]> {
// Fetch from your database
return [];
}
export default async function PaymentsPage() {
const payments = await getPayments();
return (
<div className="container py-10">
<h1 className="mb-6 text-2xl font-bold">Payments</h1>
<DataTable columns={columns} data={payments} />
</div>
);
}
Need Custom Admin Dashboards?
We build data-rich dashboards with advanced tables, charts, and real-time updates. Contact us to discuss your project.