Data Table
Generic data-driven table built on TanStack Table + Hex UI Table primitives. Pass columns + data; add sorting/filtering/pagination via TanStack hooks.
| Status | 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 dlx @hex-core/cli add data-tableWith 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.
"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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
columnsrequired | object | — | ColumnDef<TData, TValue>[] from @tanstack/react-table |
datarequired | object | — | Array of row data |
caption | ReactNode | — | Visible caption rendered below the table; announced by screen readers when entering the table |
aria-label | string | — | Accessible 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
Verified against @hex-core/components@1.12.0