Skip to content

Data Table

Build sorting, filtering, and pagination with TanStack Table.

Upstream documentation

Examples

Visible columns
Recent payments
StatusAmount
successken99@example.com$316.00
successabe45@example.com$242.00
processingmonserrat44@example.com$837.00
successsilas22@example.com$874.00

0 of 8 shown payments selected.

Page 1 of 2

Installation

Build a data table from Table and TanStack Table. Keep columns and features in your application rather than hiding them in one universal component.

Install recipe dependencies
pnpm dlx shadcn@latest add 'https://ninaero.pages.dev/r/table.json'pnpm dlx shadcn@latest add 'https://ninaero.pages.dev/r/button.json'pnpm dlx shadcn@latest add 'https://ninaero.pages.dev/r/input-group.json'pnpm dlx shadcn@latest add 'https://ninaero.pages.dev/r/checkbox.json'pnpm dlx shadcn@latest add 'https://ninaero.pages.dev/r/badge.json'pnpm dlx shadcn@latest add 'https://ninaero.pages.dev/r/field.json'pnpm add @tanstack/react-table@^9.3.1

Usage

A complete example using the installed component and hook paths.

Data Table usage
"use client";import { columnFilteringFeature, columnVisibilityFeature, createColumnHelper, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel, filterFn_includesString, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, sortFn_alphanumeric, sortFn_text, tableFeatures, useTable, type ColumnFiltersState, type ColumnVisibilityState, type PaginationState, type SortingState } from "@tanstack/react-table";import { useId, useState } from "react";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Checkbox } from "@/components/ui/checkbox";import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";import { InputGroup, InputGroupInput } from "@/components/ui/input-group";import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";type Payment = {  id: string;  amount: number;  status: "pending" | "processing" | "success" | "failed";  email: string;};const payments: Payment[] = [  { id: "728ed52f", amount: 316, status: "success", email: "ken99@example.com" },  { id: "489e1d42", amount: 242, status: "success", email: "abe45@example.com" },  { id: "9a7b2c11", amount: 837, status: "processing", email: "monserrat44@example.com" },  { id: "c2d4e6f8", amount: 874, status: "success", email: "silas22@example.com" },  { id: "a1b3c5d7", amount: 721, status: "failed", email: "carmella@example.com" },  { id: "f6e4d2c0", amount: 125, status: "pending", email: "m@example.com" },  { id: "3e5a7c9d", amount: 489, status: "processing", email: "example@gmail.com" },  { id: "8f6d4b2a", amount: 560, status: "success", email: "nina@example.com" },];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 },});type DataTableFeatures = typeof features;const columnHelper = createColumnHelper<DataTableFeatures, Payment>();const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });const columns = columnHelper.columns([  columnHelper.display({    id: "select",    enableHiding: false,    enableSorting: false,    header: ({ table }) => (      <Checkbox        aria-label="Select all payments on this page"        checked={table.getIsAllPageRowsSelected()}        indeterminate={table.getIsSomePageRowsSelected()}        onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked === true)}      />    ),    cell: ({ row }) => (      <Checkbox        aria-label={`Select payment for ${row.original.email}`}        checked={row.getIsSelected()}        onCheckedChange={(checked) => row.toggleSelected(checked === true)}      />    ),  }),  columnHelper.accessor("status", {    header: "Status",    cell: ({ getValue }) => <Badge variant="outline">{getValue()}</Badge>,  }),  columnHelper.accessor("email", {    header: ({ column }) => {      const sorted = column.getIsSorted();      return (        <Button          type="button"          variant="ghost"          size="sm"          aria-label="Sort by email"          aria-pressed={sorted !== false}          onClick={() => column.toggleSorting(sorted === "asc")}        >          Email{" "}          <span aria-hidden="true">{sorted === "asc" ? "↑" : sorted === "desc" ? "↓" : "↕"}</span>        </Button>      );    },    filterFn: "includesString",    sortFn: "alphanumeric",  }),  columnHelper.accessor("amount", {    header: () => <span className="block text-right">Amount</span>,    cell: ({ getValue }) => (      <span className="block text-right font-medium">{currency.format(getValue())}</span>    ),    enableSorting: false,  }),]);const visibilityColumns = [  { id: "status", label: "Status" },  { id: "email", label: "Email" },  { id: "amount", label: "Amount" },] as const;function DataTablePreview() {  const tableId = useId();  const [sorting, setSorting] = useState<SortingState>([]);  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);  const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>({});  const [rowSelection, setRowSelection] = useState({});  const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 4 });  const table = useTable({    features,    data: payments,    columns,    getRowId: (payment: Payment) => payment.id,    onSortingChange: setSorting,    onColumnFiltersChange: setColumnFilters,    onColumnVisibilityChange: setColumnVisibility,    onRowSelectionChange: setRowSelection,    onPaginationChange: setPagination,    state: { sorting, columnFilters, columnVisibility, rowSelection, pagination },  });  const emailColumn = table.getColumn("email");  const emailFilter = emailColumn?.getFilterValue();  const visibleColumns = table.getVisibleLeafColumns();  const pageCount = Math.max(1, table.getPageCount());  return (    <div className="flex flex-col gap-4">      <div className="flex flex-col gap-4 rounded-xl border bg-background p-4 sm:flex-row sm:items-end sm:justify-between">        <Field className="w-full max-w-sm">          <FieldLabel htmlFor={`${tableId}-email`}>Filter by email</FieldLabel>          <InputGroup>            <InputGroupInput              id={`${tableId}-email`}              type="search"              autoComplete="off"              placeholder="Filter emails…"              value={typeof emailFilter === "string" ? emailFilter : ""}              onChange={(event) => emailColumn?.setFilterValue(event.target.value)}            />          </InputGroup>        </Field>        <FieldSet className="min-w-0">          <FieldLegend variant="label">Visible columns</FieldLegend>          <FieldGroup className="flex flex-row flex-wrap gap-x-4 gap-y-2">            {visibilityColumns.map(({ id, label }) => {              const column = table.getColumn(id);              return (                <Field key={id} orientation="horizontal" className="w-auto gap-2">                  <Checkbox                    id={`${tableId}-column-${id}`}                    checked={column?.getIsVisible() ?? false}                    onCheckedChange={(checked) => column?.toggleVisibility(checked === true)}                  />                  <FieldLabel htmlFor={`${tableId}-column-${id}`}>{label}</FieldLabel>                </Field>              );            })}          </FieldGroup>        </FieldSet>      </div>      <div className="overflow-hidden rounded-xl border bg-background">        <Table>          <TableCaption className="sr-only">Recent payments</TableCaption>          <TableHeader>            {table.getHeaderGroups().map((headerGroup) => (              <TableRow key={headerGroup.id}>                {headerGroup.headers.map((header) => {                  const sorted = header.column.getIsSorted();                  return (                    <TableHead                      key={header.id}                      aria-sort={                        sorted === "asc"                          ? "ascending"                          : sorted === "desc"                            ? "descending"                            : undefined                      }                      className={header.column.id === "select" ? "w-10" : undefined}                    >                      {header.isPlaceholder ? null : <table.FlexRender header={header} />}                    </TableHead>                  );                })}              </TableRow>            ))}          </TableHeader>          <TableBody>            {table.getRowModel().rows.length > 0 ? (              table.getRowModel().rows.map((row) => (                <TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined}>                  {row.getVisibleCells().map((cell) => (                    <TableCell key={cell.id}>                      <table.FlexRender cell={cell} />                    </TableCell>                  ))}                </TableRow>              ))            ) : (              <TableRow>                <TableCell colSpan={visibleColumns.length} className="h-24 text-center">                  No payments match this email.                </TableCell>              </TableRow>            )}          </TableBody>        </Table>      </div>      <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">        <p className="text-sm text-muted-foreground" aria-live="polite">          {table.getFilteredSelectedRowModel().rows.length} of{" "}          {table.getFilteredRowModel().rows.length} shown payments selected.        </p>        <div className="flex items-center justify-between gap-3 sm:justify-end">          <span className="text-sm text-muted-foreground">            Page {pagination.pageIndex + 1} of {pageCount}          </span>          <div className="flex gap-2">            <Button              type="button"              variant="outline"              size="sm"              onClick={() => table.previousPage()}              disabled={!table.getCanPreviousPage()}            >              Previous            </Button>            <Button              type="button"              variant="outline"              size="sm"              onClick={() => table.nextPage()}              disabled={!table.getCanNextPage()}            >              Next            </Button>          </div>        </div>      </div>    </div>  );}export default function Demo() {  return <DataTablePreview />;}

Composition

Build a data table from Table and TanStack Table. Keep columns and features in your application rather than hiding them in one universal component.