Data Table

Generic data-driven table built on TanStack Table + Hex UI Table primitives. Pass columns + data; add sorting/filtering/pagination via TanStack hooks.

StatusEmail
Amount
success
ken99@yahoo.com
$316.00
success
abe45@gmail.com
$242.00
processing
monserrat44@yahoo.com
$837.00
success
silas22@hotmail.com
$874.00
failed
carmella@qmail.com
$721.00

Installation

pnpm
pnpm dlx @hex-core/cli add data-table

With column visibility toggle

Parent owns a visibility map and filters columns before passing them to DataTable. A DropdownMenu of checkbox items lets users hide noisy columns without losing the row model.

tsx
"use client";

import { useMemo, useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
import {
  Button,
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui";

type Payment = { id: string; amount: number; status: "pending" | "paid" | "failed"; email: string };

const ALL_COLUMNS: ColumnDef<Payment>[] = [
  { id: "status", accessorKey: "status", header: "Status" },
  { id: "email", accessorKey: "email", header: "Email" },
  { id: "amount", accessorKey: "amount", header: "Amount" },
];

const DATA: Payment[] = [
  { id: "1", amount: 100, status: "paid", email: "a@x.com" },
  { id: "2", amount: 250, status: "pending", email: "b@x.com" },
];

export function Example() {
  const [visibility, setVisibility] = useState<Record<string, boolean>>({
    status: true,
    email: true,
    amount: true,
  });
  const visibleColumns = useMemo(
    () => ALL_COLUMNS.filter((c) => visibility[c.id ?? ""] !== false),
    [visibility],
  );
  return (
    <div className="space-y-2">
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="outline" size="sm" className="ml-auto">
            Columns
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
          <DropdownMenuSeparator />
          {ALL_COLUMNS.map((column) => {
            const id = column.id ?? "";
            return (
              <DropdownMenuCheckboxItem
                key={id}
                checked={visibility[id] !== false}
                onCheckedChange={(checked) =>
                  setVisibility((prev) => ({ ...prev, [id]: checked === true }))
                }
                className="capitalize"
              >
                {id}
              </DropdownMenuCheckboxItem>
            );
          })}
        </DropdownMenuContent>
      </DropdownMenu>
      <DataTable columns={visibleColumns} data={DATA} aria-label="Payments" />
    </div>
  );
}

Clamp page when data shrinks

Stay on a valid page when the underlying data shrinks — common when a filter or search query reduces the source rows. The parent owns pageIndex, and we clamp it derived-style so an old offset never slices past the end. No useEffect-driven reset, no one-frame mismatch where a stale pageIndex renders an empty page against the new shorter array.

tsx
"use client";

import { useMemo, useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
import {
  Input,
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui";

type Payment = { id: string; amount: number; status: "pending" | "paid" | "failed"; email: string };

const columns: ColumnDef<Payment>[] = [
  { accessorKey: "status", header: "Status" },
  { accessorKey: "email", header: "Email" },
  { accessorKey: "amount", header: "Amount" },
];

const ALL_ROWS: Payment[] = Array.from({ length: 23 }, (_, i) => ({
  id: String(i + 1),
  amount: 100 + i * 25,
  status: i % 3 === 0 ? "pending" : "paid",
  email: `user${i + 1}@example.com`,
}));

const PAGE_SIZE = 5;

export function Example() {
  const [query, setQuery] = useState("");
  const [pageIndex, setPageIndex] = useState(0);

  const filtered = useMemo(
    () => ALL_ROWS.filter((row) => row.email.includes(query.trim().toLowerCase())),
    [query],
  );

  const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));

  // Clamp the user's stored pageIndex against the current page count.
  // If a filter shrinks the result from 5 pages to 2, we slice page 2
  // instead of an out-of-range slice — the user never sees an empty page.
  // Avoids the React derived-state anti-pattern (useEffect resetting state
  // on a deps change, which renders once with stale data before snapping).
  const safePageIndex = Math.min(pageIndex, pageCount - 1);
  const pageData = useMemo(
    () => filtered.slice(safePageIndex * PAGE_SIZE, (safePageIndex + 1) * PAGE_SIZE),
    [filtered, safePageIndex],
  );

  return (
    <div className="space-y-3">
      <Input
        placeholder="Filter by email…"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />
      <DataTable columns={columns} data={pageData} aria-label="Payments" />
      <Pagination>
        <PaginationContent>
          <PaginationItem>
            <PaginationPrevious
              href="#"
              aria-disabled={safePageIndex === 0}
              onClick={(event) => {
                event.preventDefault();
                setPageIndex((i) => Math.max(0, i - 1));
              }}
            />
          </PaginationItem>
          {Array.from({ length: pageCount }).map((_, i) => (
            <PaginationItem key={i}>
              <PaginationLink
                href="#"
                isActive={i === safePageIndex}
                onClick={(event) => {
                  event.preventDefault();
                  setPageIndex(i);
                }}
              >
                {i + 1}
              </PaginationLink>
            </PaginationItem>
          ))}
          <PaginationItem>
            <PaginationNext
              href="#"
              aria-disabled={safePageIndex >= pageCount - 1}
              onClick={(event) => {
                event.preventDefault();
                setPageIndex((i) => Math.min(pageCount - 1, i + 1));
              }}
            />
          </PaginationItem>
        </PaginationContent>
      </Pagination>
    </div>
  );
}

API Reference

PropTypeDefaultDescription
columnsrequired
objectColumnDef<TData, TValue>[] from @tanstack/react-table
datarequired
objectArray of row data
caption
ReactNodeVisible caption rendered below the table; announced by screen readers when entering the table
aria-label
stringAccessible label forwarded as aria-label on the underlying <table>; use when no visible caption is shown

AI Guidance

When to use

Use for tabular data that needs sorting, filtering, pagination, or row selection. Define columns once, feed data — TanStack handles the row model. Add more features incrementally (getSortedRowModel, getFilteredRowModel, getPaginationRowModel).

When not to use

Don't use for static/simple tables (use Table primitives directly). Don't use for virtualized very-large lists (use TanStack Virtual). Don't use for grid layouts (use CSS grid). DataTable is a Client Component (uses useReactTable hook) — fetch data in a Server Component and pass it as props.

Common mistakes

  • Forgetting getCoreRowModel() (table renders nothing)
  • Recreating columns array on every render (breaks memoization — wrap in useMemo or define outside the component)
  • Using accessorKey with nested paths without accessorFn
  • Not adding filter/sort row models when those features are needed
  • Shipping a table without `caption` or `aria-label` — the table is unlabelled to assistive tech

Accessibility

Pass either `caption` (visible) or `aria-label` so screen readers announce the table when the user enters it. Add aria-sort to sortable column headers. Announce filter/sort changes via aria-live for dynamic updates.

Related components

Token budget: 900