DataTable
A fully-featured table built on TanStack Table v8 with sorting, filtering, and pagination out of the box. Renders using the Table primitive components from Issue #125.
TanStack Table v8SortableFilterablePaginatedLoading StateEmpty State
Install
$
npx react-principles add data-table01
Features
- ✓Sorting: click column headers to sort asc/desc/none
- ✓Global filter: search input filters across all columns
- ✓Pagination: Previous/Next controls with page info
- ✓Loading state: skeleton rows while data loads
- ✓Empty state: custom content when no rows match
- ✓Controlled & uncontrolled: all state (sorting, filter, pagination) supports both modes
- ✓TypeScript generic:
DataTable<TData>for type-safe columns
02
Live Demo
Fully interactive — try sorting columns, filtering rows, and navigating pages.
search
Name | Email | Role | Status | Created |
|---|---|---|---|---|
| Alice Johnson | alice@example.com | admin | active | Jan 15, 2024 |
| Bob Smith | bob@example.com | editor | active | Feb 10, 2024 |
| Carol Williams | carol@example.com | viewer | active | Feb 20, 2024 |
| David Brown | david@example.com | editor | inactive | Mar 5, 2024 |
| Eva Martinez | eva@example.com | admin | active | Mar 12, 2024 |
| Frank Garcia | frank@example.com | viewer | active | Mar 18, 2024 |
| Grace Lee | grace@example.com | editor | active | Apr 1, 2024 |
| Henry Wilson | henry@example.com | viewer | inactive | Apr 10, 2024 |
Page 1 of 3 (20 rows)
03
Code Snippet
src/ui/DataTable.tsx
import { DataTable } from "@/ui/DataTable"; import type { ColumnDef } from "@tanstack/react-table"; import { useMemo } from "react"; interface User { id: string; name: string; email: string; role: string; status: string; } const columns: ColumnDef<User>[] = [ { accessorKey: "name", header: "Name" }, { accessorKey: "email", header: "Email" }, { accessorKey: "role", header: "Role" }, { accessorKey: "status", header: "Status" }, ]; export function UserList() { const data = useMemo(() => fetchUsers(), []); return ( <DataTable columns={columns} data={data} pageSize={8} filterPlaceholder="Search users..." /> ); }
04
Copy-Paste (Single File)
DataTable.tsx
"use client"; import { useState, type ReactNode } from "react"; import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, flexRender, type ColumnDef, type SortingState, type PaginationState, type OnChangeFn, } from "@tanstack/react-table"; import { cn } from "@/lib/utils"; import { Table } from "./Table"; import { Button } from "./Button"; import { Skeleton } from "./Skeleton"; export interface DataTableProps<TData> { columns: Array<ColumnDef<TData>>; data: TData[]; sorting?: SortingState; onSortingChange?: OnChangeFn<SortingState>; globalFilter?: string; onGlobalFilterChange?: OnChangeFn<string>; pagination?: PaginationState; onPaginationChange?: OnChangeFn<PaginationState>; pageSize?: number; isLoading?: boolean; emptyState?: ReactNode; filterPlaceholder?: string; className?: string; } function SortIcon({ direction }: { direction: false | "asc" | "desc" }) { if (!direction) return null; return ( <span className="material-symbols-outlined text-[14px]"> {direction === "asc" ? "arrow_upward" : "arrow_downward"} </span> ); } function DataTableSkeleton({ colCount, rowCount }: { colCount: number; rowCount: number }) { return ( <> {Array.from({ length: rowCount }).map((_, i) => ( <Table.Row key={i}> {Array.from({ length: colCount }).map((__, j) => ( <Table.Cell key={j}> <Skeleton variant="line" className="h-4 w-full" /> </Table.Cell> ))} </Table.Row> ))} </> ); } export function DataTable<TData>({ columns, data, sorting: controlledSorting, onSortingChange, globalFilter: controlledFilter, onGlobalFilterChange, pagination: controlledPagination, onPaginationChange, pageSize = 10, isLoading = false, emptyState, filterPlaceholder = "Filter all columns...", className, }: DataTableProps<TData>) { const [internalSorting, setInternalSorting] = useState<SortingState>([]); const [internalFilter, setInternalFilter] = useState(""); const [internalPagination, setInternalPagination] = useState<PaginationState>({ pageIndex: 0, pageSize, }); const sorting = controlledSorting ?? internalSorting; const globalFilter = controlledFilter ?? internalFilter; const pagination = controlledPagination ?? internalPagination; const isSortingControlled = controlledSorting !== undefined; const isFilterControlled = controlledFilter !== undefined; const isPaginationControlled = controlledPagination !== undefined; const handleSortingChange: OnChangeFn<SortingState> = (updater) => { const next = typeof updater === "function" ? updater(sorting) : updater; if (!isSortingControlled) setInternalSorting(next); onSortingChange?.(updater); }; const handleFilterChange: OnChangeFn<string> = (updater) => { const next = typeof updater === "function" ? updater(globalFilter) : updater; if (!isFilterControlled) setInternalFilter(next); onGlobalFilterChange?.(updater); }; const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => { const next = typeof updater === "function" ? updater(pagination) : updater; if (!isPaginationControlled) setInternalPagination(next); onPaginationChange?.(updater); }; const table = useReactTable({ data, columns, state: { sorting, globalFilter, pagination }, onSortingChange: handleSortingChange, onGlobalFilterChange: handleFilterChange, onPaginationChange: handlePaginationChange, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), }); const isEmpty = !isLoading && table.getRowModel().rows.length === 0; return ( <div className={cn("space-y-4", className)}> <div className="relative"> <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-[18px] text-slate-400"> search </span> <input type="text" value={globalFilter} onChange={(e) => handleFilterChange(e.target.value)} placeholder={filterPlaceholder} className={cn( "h-10 w-full rounded-lg border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-900 outline-hidden transition-all", "hover:border-slate-300 focus:border-primary focus:ring-2 focus:ring-primary/20", "dark:border-[#1f2937] dark:bg-[#0d1117] dark:text-white dark:hover:border-slate-600", )} /> </div> <Table> <Table.Header> {table.getHeaderGroups().map((headerGroup) => ( <Table.Row key={headerGroup.id}> {headerGroup.headers.map((header) => ( <Table.Head key={header.id} className={cn(header.column.getCanSort() && "cursor-pointer select-none")} onClick={header.column.getToggleSortingHandler()} > <div className="flex items-center gap-1"> {flexRender(header.column.columnDef.header, header.getContext())} <SortIcon direction={header.column.getIsSorted()} /> </div> </Table.Head> ))} </Table.Row> ))} </Table.Header> <Table.Body> {isLoading ? ( <DataTableSkeleton colCount={columns.length} rowCount={pagination.pageSize} /> ) : isEmpty ? ( <Table.Row> <Table.Cell colSpan={columns.length} className="h-48 text-center"> {emptyState ?? ( <p className="text-sm text-slate-500 dark:text-slate-400">No results found.</p> )} </Table.Cell> </Table.Row> ) : ( table.getRowModel().rows.map((row) => ( <Table.Row key={row.id}> {row.getVisibleCells().map((cell) => ( <Table.Cell key={cell.id}> {flexRender(cell.column.columnDef.cell, cell.getContext())} </Table.Cell> ))} </Table.Row> )) )} </Table.Body> </Table> <div className="flex items-center justify-between"> <p className="text-sm text-slate-500 dark:text-slate-400"> Page {pagination.pageIndex + 1} of {table.getPageCount()}{" "} ({table.getFilteredRowModel().rows.length} rows) </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> ); }
05
Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | ColumnDef<TData>[] | — | TanStack Table column definitions. |
data | TData[] | — | Array of row data to display. |
sorting | SortingState | — | Controlled sorting state. |
onSortingChange | OnChangeFn<SortingState> | — | Called when sorting changes. |
globalFilter | string | — | Controlled global filter string. |
onGlobalFilterChange | OnChangeFn<string> | — | Called when the filter changes. |
pagination | PaginationState | — | Controlled pagination state. |
onPaginationChange | OnChangeFn<PaginationState> | — | Called when pagination changes. |
pageSize | number | 10 | Initial page size in uncontrolled mode. |
isLoading | boolean | false | Shows skeleton rows while loading. |
emptyState | ReactNode | — | Custom content rendered when no rows match. |
filterPlaceholder | string | "Filter all columns..." | Placeholder text for the filter input. |