Agent Workspace
← Back to catalog
Communityuiv0.1.0Recommended

shadcn + TanStack Data Table

The canonical admin-UI table, wired correctly.

Agent WorkspaceSigned (unverified)Verified publisher·Updated 2026-04-16·~0 installs this month · saved ~0 tokens

Install

npx attrition-sh pack install shadcn-data-table
Skill `shadcn-data-table` is installed at .claude/skills/shadcn-data-table/SKILL.md. Invoke whenever the user needs a list-of-records UI with sorting, filtering, pagination, or selection. Prefer the TanStack + shadcn baseline over hand-rolled `<table>` markup; respect sticky-header and keyboard-sort requirements.

Raw Markdown

Machine-readable body for agent ingestion or copy/paste.

Download as .md

Telemetry

Not yet measured

Skipping this saves ~25,000 tokens / 45 min of research.

Methodology

Measured 2026-04-16

Prompted fresh Claude Sonnet 4.6 with 'build a production data table in Next.js with sorting, server pagination, column visibility, row selection, and a11y'. Measured tokens until the output included TanStack useReactTable, manualPagination + pageCount, getRowId, aria-sort, URL-synced state, and empty/loading states. Averaged over 3 runs.

Summary

A content-complete data table built on TanStack Table v8 and shadcn/ui primitives. Ships with sortable columns, server-side pagination, column visibility toggles, row selection, per-column filters, sticky header, skeleton loading, a11y-correct header semantics, and an empty state. Replaces the 45-minute stub that every project writes twice.

Fit and expected payoff

When this pack earns its extra structure, when to skip it, and what it should improve.

Situations where this pack earns its extra structure.

  • You are rendering 20–10,000 rows of structured data that users sort/filter/select.
  • Your team has already standardised on shadcn/ui primitives.
  • You need server-side pagination and filtering (URL-synced).
  • You need row selection for bulk actions (archive, assign, delete).

Keeps the pack from becoming a default hammer.

  • You need virtualised 100k+ row rendering — reach for ag-grid or TanStack Virtual directly.
  • The data is better visualised as a board/kanban or a tree — don't force a table.
  • You only have <10 rows — a simple `<ul>` or card grid is clearer.
  • You need in-cell editing with complex validation — use a spreadsheet-style lib.

Expected outcomes if implemented well.

  • Sortable columns via keyboard; ARIA `aria-sort` reflects current direction.
  • Server-pagination synced to URL so refresh preserves state.
  • Column visibility menu; per-user preferences persisted to localStorage.
  • Selection row spans checkbox, row-click (optional), and shift-range-select.
  • Skeleton loading on first fetch; empty state with call-to-action on zero rows.

Minimal instructions

Smallest useful starting point.

## Minimal setup

```bash
pnpm add @tanstack/react-table
pnpm dlx shadcn@latest add table checkbox dropdown-menu button input
```

```tsx
// app/users/columns.tsx
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/components/ui/checkbox";

export type User = { id: string; name: string; email: string; role: string };

export const columns: ColumnDef<User>[] = [
  {
    id: "select",
    header: ({ table }) => (
      <Checkbox
        checked={table.getIsAllPageRowsSelected()}
        onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
        aria-label="Select all"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        onCheckedChange={(v) => row.toggleSelected(!!v)}
        aria-label="Select row"
      />
    ),
    enableSorting: false,
  },
  { accessorKey: "name", header: "Name" },
  { accessorKey: "email", header: "Email" },
  { accessorKey: "role", header: "Role" },
];
```

Pair with the `<DataTable>` wrapper below (see full instructions).

Full instructions

Complete natural-language instruction set.

## Full reference: production data table

### 1. Why TanStack + shadcn

TanStack Table v8 is headless: it owns state (sorting, pagination, selection, filters) and exposes row models. shadcn gives you styled `<Table>`, `<Checkbox>`, `<DropdownMenu>`, `<Button>`. Together you get a real table in ~300 lines that you own and can read.

### 2. The `<DataTable>` wrapper

```tsx
"use client";
import * as React from "react";
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
  SortingState,
  VisibilityState,
  RowSelectionState,
} from "@tanstack/react-table";
import {
  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";

export function DataTable<TData, TValue>({
  columns, data, totalCount, pagination, setPagination,
}: {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
  totalCount: number;
  pagination: { pageIndex: number; pageSize: number };
  setPagination: React.Dispatch<React.SetStateAction<{ pageIndex: number; pageSize: number }>>;
}) {
  const [sorting, setSorting] = React.useState<SortingState>([]);
  const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});

  const table = useReactTable({
    data,
    columns,
    pageCount: Math.ceil(totalCount / pagination.pageSize),
    state: { sorting, columnVisibility, rowSelection, pagination },
    onSortingChange: setSorting,
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    onPaginationChange: setPagination,
    manualPagination: true,
    manualSorting: true,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  return (
    <div className="space-y-2">
      <div className="rounded-md border">
        <Table>
          <TableHeader className="sticky top-0 bg-background z-10">
            {table.getHeaderGroups().map((hg) => (
              <TableRow key={hg.id}>
                {hg.headers.map((h) => {
                  const sort = h.column.getIsSorted();
                  return (
                    <TableHead
                      key={h.id}
                      aria-sort={sort === "asc" ? "ascending" : sort === "desc" ? "descending" : "none"}
                    >
                      {h.isPlaceholder ? null : h.column.getCanSort() ? (
                        <button
                          className="flex items-center gap-1"
                          onClick={h.column.getToggleSortingHandler()}
                        >
                          {flexRender(h.column.columnDef.header, h.getContext())}
                          {sort === "asc" ? " ↑" : sort === "desc" ? " ↓" : ""}
                        </button>
                      ) : (
                        flexRender(h.column.columnDef.header, h.getContext())
                      )}
                    </TableHead>
                  );
                })}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={columns.length} className="h-24 text-center">
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      <div className="flex items-center justify-between">
        <div className="text-sm text-muted-foreground">
          {table.getFilteredSelectedRowModel().rows.length} of {totalCount} selected.
        </div>
        <div className="flex items-center gap-2">
          <Button size="sm" variant="outline"
            onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
            Previous
          </Button>
          <Button size="sm" variant="outline"
            onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
            Next
          </Button>
        </div>
      </div>
    </div>
  );
}
```

### 3. URL-synced pagination

Persist `pageIndex`, `pageSize`, and sort in the URL via Next's `useSearchParams` + `router.replace`. This makes refresh and share-links Just Work.

```tsx
const sp = useSearchParams();
const router = useRouter();
const pagination = {
  pageIndex: Number(sp.get("page") ?? "0"),
  pageSize: Number(sp.get("size") ?? "20"),
};
const setPagination = (update) => {
  const next = typeof update === "function" ? update(pagination) : update;
  const params = new URLSearchParams(sp);
  params.set("page", String(next.pageIndex));
  params.set("size", String(next.pageSize));
  router.replace(`?${params.toString()}`);
};
```

### 4. Filtering

- **Per-column filter input** bound to `column.setFilterValue()`; debounce 200ms.
- **Global search** bound to `table.setGlobalFilter()`; for server-side, swap for a `q` query param.
- **Faceted filters** (multi-select by value): use `getFacetedUniqueValues()` + a popover with checkboxes. Linear's approach.

### 5. Column visibility

```tsx
<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button variant="outline">Columns</Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent>
    {table.getAllColumns().filter((c) => c.getCanHide()).map((c) => (
      <DropdownMenuCheckboxItem
        key={c.id}
        checked={c.getIsVisible()}
        onCheckedChange={(v) => c.toggleVisibility(!!v)}
      >
        {c.id}
      </DropdownMenuCheckboxItem>
    ))}
  </DropdownMenuContent>
</DropdownMenu>
```

Persist visibility to localStorage under a per-table key.

### 6. Loading and empty states

- **Loading**: render 5 skeleton rows matching column widths. Do not collapse the header.
- **Empty (no data yet)**: illustration + CTA ("Create your first user").
- **Empty (filtered out)**: "No results match your filters." + "Clear filters" button.

### 7. Accessibility

- `<table>` semantics (don't use `<div role="table">` without strong reason).
- `aria-sort` on sortable headers; toggle is a `<button>` with visible sort arrow.
- Checkbox header `aria-label="Select all"`; row checkbox `aria-label="Select row <identifier>"`.
- Row focus style visible with `:focus-visible`.
- Sticky header stacks above content with `z-10` and opaque background.

### 8. Performance

- At >500 rows, enable `@tanstack/react-virtual`. Wrap `TableBody` with a virtualiser.
- Memoise `columns` with `useMemo` — recreating the array on every render thrashes TanStack state.
- Avoid fetching all rows: prefer cursor or offset pagination at the API.

### 9. Testing

- Unit: column defs render expected strings for fixtures.
- Integration (Playwright): sort toggles, paginate, select row, bulk-action button enables.
- A11y: axe scan; NVDA sort announcement.

### 10. Common pitfalls

1. `columns` recreated inline in render — sorting/selection state resets every render.
2. `manualPagination` without supplying `pageCount` — the Next button never disables.
3. Row IDs derived from array index — selection breaks on sort. Use `getRowId: (r) => r.id`.
4. Sticky header with transparent background — rows bleed through on scroll.

Evaluation checklist

These checks should pass before you consider the pattern production-ready.

  • Header click sorts column; `aria-sort` updates; arrow indicator visible.
  • Refresh preserves page, size, and sort via URL params.
  • Column visibility menu hides/shows columns and persists across reloads.
  • Select-all checkbox selects all rows on the current page (not all rows across pages).
  • Empty state renders when result set is zero, with distinct copy for 'no data' vs 'filtered out'.
  • Skeleton rows render on first fetch; header stays pinned.
  • axe-core scan reports zero violations.

Common failure modes

Every check below traces back to a specific production failure. Read as: "I would think about X because in production Y can happen."

  • Mid

    User clicks sort header; table resets to unsorted on next render

    Trigger
    `columns` array re-created every render; TanStack sees a new ref and resets state
    Prevention
    Wrap the columns definition with `useMemo`
  • Mid

    User selects row 3, sorts a column, now a different row is selected

    Trigger
    Row IDs default to row index; sorting changes indices
    Prevention
    Provide a stable id via `getRowId: (row) => row.id`
  • Mid

    Next-page button never disables even on the last page (server pagination)

    Trigger
    `pageCount` missing; TanStack can't know when it's at the end
    Prevention
    Compute `Math.ceil(total / pageSize)` and pass as `pageCount`
  • Junior

    Sticky header looks transparent; rows scroll through it

    Trigger
    Header has no background and no z-index against the scroll container
    Prevention
    Apply `bg-background z-10` (or design token) on the sticky header cell

How this pack stacks up

Head-to-head notes vs alternative patterns.

AlternativeAxisWinnerNote
complexityTieOrthogonal concerns — tables render structured records, palettes trigger commands. Use both.
complexityThis packAG Grid wins on features (pivot, grouping, 100k rows). TanStack + shadcn wins on bundle size, readability, and ownership.

How this pack connects

Injection surface, allow-list, and known issues

Low

2026-04-16

No tool permissions granted.

Version history

  1. v0.1.0

    2026-04-16

    Added

    • Initial pack with DataTable wrapper, URL-synced pagination, column visibility, row selection
    • A11y rules and sticky-header recipe
    • Skeleton loading + dual empty-state pattern

    Seed pack — first release.

Official docs and implementation references

Reference implementations