---
name: data-tables
description: >
  Build data table views with TanStack Table + design system Table component.
  Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell,
  TableCaption. ColumnDef object definitions with custom cell renderers using
  DS components. Sortable headers with Button variant="ghost" + sort icons.
  Row selection with Checkbox and data-state="selected" attribute. flexRender
  for header/cell content. Infinite scroll with TanStack Query. Reusable
  DataTable wrapper pattern. Activate when building data tables with sorting,
  filtering, pagination, or row selection.
type: composition
library: '@loke/design-system'
library_version: '2.0.0-rc.6'
requires:
  - getting-started
  - interactive-components
sources:
  - 'LOKE/merchant-frontends:packages/design-system/src/components/table'
  - 'LOKE/merchant-frontends:packages/design-system/src/components/pagination'
  - 'LOKE/merchant-frontends:apps/office/src/components/data-table/index.tsx'
  - 'LOKE/merchant-frontends:apps/office/src/components/data-table/standard-table.tsx'
  - 'LOKE/merchant-frontends:apps/office/src/components/data-table/rows.tsx'
---

# Data Tables

This skill builds on **getting-started** and **interactive-components**. Read them first.

## Setup

Install TanStack Table alongside the design system:

```bash
bun add @tanstack/react-table
```

Minimal integration connecting TanStack Table to DS Table components:

```tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@loke/design-system/table";
import {
  type ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";

interface Payment {
  id: string;
  amount: number;
  status: "pending" | "completed" | "failed";
}

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

function PaymentsTable({ data }: { data: Payment[] }) {
  const table = useReactTable({
    columns,
    data,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <Table>
      <TableHeader>
        {table.getHeaderGroups().map((headerGroup) => (
          <TableRow key={headerGroup.id}>
            {headerGroup.headers.map((header) => (
              <TableHead key={header.id}>
                {flexRender(
                  header.column.columnDef.header,
                  header.getContext(),
                )}
              </TableHead>
            ))}
          </TableRow>
        ))}
      </TableHeader>
      <TableBody>
        {table.getRowModel().rows.map((row) => (
          <TableRow key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <TableCell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </TableCell>
            ))}
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

## Core Patterns

### Table component composition

The DS Table maps directly to semantic HTML table elements. Always nest them correctly:

```
Table
  TableHeader
    TableRow
      TableHead (one per column)
  TableBody
    TableRow (one per data row)
      TableCell (one per column)
  TableFooter (optional)
    TableRow
      TableCell
  TableCaption (optional, direct child of Table)
```

The `Table` component wraps itself in a `div.relative.w-full.overflow-auto` for horizontal scroll on small screens. `TableRow` includes built-in `hover:bg-muted/50` and `data-[state=selected]:bg-muted` styles.

### ColumnDef with custom cell renderers

Use DS components inside `cell` functions. Always use `flexRender` for both headers and cells:

```tsx
import { Button } from "@loke/design-system/button";
import type { ColumnDef } from "@tanstack/react-table";

const columns: ColumnDef<Discount>[] = [
  {
    accessorKey: "name",
    cell: ({ row }) => (
      <div className="min-w-0">
        <div className="truncate font-medium text-sm">
          {row.original.name}
        </div>
      </div>
    ),
    header: "Discount",
    minSize: 200,
  },
  {
    accessorKey: "status",
    cell: ({ row }) => <DiscountStatusBadge status={row.original.status} />,
    header: "Status",
    size: 120,
  },
  {
    accessorFn: (d) => d.id,
    cell: ({ row }) => (
      <Button
        className="size-10 rounded-full"
        onClick={(e) => {
          e.stopPropagation();
          handleRowClick(row.original);
        }}
        size="icon"
        variant="ghost"
      >
        <ArrowRight size="md" />
      </Button>
    ),
    enableSorting: false,
    header: "",
    id: "actions",
    size: 40,
  },
];
```

Use `accessorKey` for direct property access, `accessorFn` for computed values. Set `id` explicitly when using `accessorFn`. Control column widths with `size`, `minSize`, and `maxSize`.

### Sortable headers

The office app pattern uses `Button variant="ghost"` with sort icons from `@loke/icons`. This is implemented in `StandardTable`:

```tsx
import { Button } from "@loke/design-system/button";
import { TableHead, TableRow } from "@loke/design-system/table";
import { ChevronsUpDownIcon, SortAscIcon, SortDescIcon } from "@loke/icons";
import { flexRender } from "@tanstack/react-table";

// Inside header rendering:
{table.getHeaderGroups().map((headerGroup) => (
  <TableRow key={headerGroup.id}>
    {headerGroup.headers.map((header) => {
      const canSort = header.column.getCanSort();
      const sortDirection = header.column.getIsSorted();

      if (canSort) {
        return (
          <TableHead key={header.id}>
            <Button
              className="w-full justify-between py-1.5"
              onClick={header.column.getToggleSortingHandler()}
              size="sm"
              variant="ghost"
            >
              <span>
                {flexRender(
                  header.column.columnDef.header,
                  header.getContext(),
                )}
              </span>
              <span>
                {sortDirection === "asc" && (
                  <SortAscIcon className="text-muted-foreground" />
                )}
                {sortDirection === "desc" && (
                  <SortDescIcon className="text-muted-foreground" />
                )}
                {!sortDirection && (
                  <ChevronsUpDownIcon className="text-muted-foreground" />
                )}
              </span>
            </Button>
          </TableHead>
        );
      }

      return (
        <TableHead key={header.id}>
          {flexRender(
            header.column.columnDef.header,
            header.getContext(),
          )}
        </TableHead>
      );
    })}
  </TableRow>
))}
```

Configure sorting state on `useReactTable`:

```tsx
import { type SortingState, getSortedRowModel } from "@tanstack/react-table";
import { useState } from "react";

const [sorting, setSorting] = useState<SortingState>([]);

const table = useReactTable({
  columns,
  data,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  onSortingChange: setSorting,
  state: { sorting },
});
```

Disable sorting on specific columns with `enableSorting: false` in the column def.

### Row selection

Add a checkbox column as the first column. Use `data-state` on `TableRow` so the DS built-in `data-[state=selected]:bg-muted` style applies:

```tsx
import { Checkbox } from "@loke/design-system/checkbox";
import type { ColumnDef } from "@tanstack/react-table";

const selectionColumn: ColumnDef<MyData> = {
  accessorKey: "isChecked",
  cell: ({ row }) => (
    <div className="flex items-center justify-center">
      <Checkbox
        checked={Boolean(row.original.isChecked)}
        onCheckedChange={(v) => {
          toggleSelection(row.original.id, Boolean(v));
        }}
        onClick={(e) => e.stopPropagation()}
      />
    </div>
  ),
  enableColumnFilter: false,
  enableSorting: false,
  header: "",
  minSize: 40,
  size: 40,
};
```

On the row, set `data-state` for styling:

```tsx
<TableRow
  data-state={row.getIsSelected() && "selected"}
  onClick={(event) => handleRowClick(row, event)}
  style={{ cursor: hasSelectableRows ? "pointer" : "default" }}
>
  {row.getVisibleCells().map((cell) => (
    <TableCell key={cell.id}>
      {flexRender(cell.column.columnDef.cell, cell.getContext())}
    </TableCell>
  ))}
</TableRow>
```

### Row actions with DropdownMenu

Add an actions column using DropdownMenu for contextual operations:

```tsx
import { Button } from "@loke/design-system/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@loke/design-system/dropdown-menu";
import { EllipsisVertical } from "@loke/icons";

const actionsColumn: ColumnDef<MyData> = {
  cell: ({ row }) => (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button size="icon" variant="ghost">
          <EllipsisVertical />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => handleEdit(row.original)}>
          Edit
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => handleDelete(row.original)}>
          Delete
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  ),
  enableSorting: false,
  header: "",
  id: "actions",
  size: 40,
};
```

Always use `e.stopPropagation()` on action buttons when rows are clickable.

### Reusable DataTable wrapper

The office app uses a generic `DataTable` wrapper (`apps/office/src/components/data-table/index.tsx`) that accepts `columns` and `data` generically, with optional props for sorting, row selection, grouping, infinite scroll, toolbars, and empty state placeholders. It internally manages `useReactTable` state and delegates rendering to `StandardTable` (semantic `<table>`) or `InfiniteScrollTable` (virtualized with `@tanstack/react-virtual`).

Key props: `sorting` and `rowSelection` accept objects with both state and `OnChangeFn` callbacks, allowing either controlled (server-driven) or uncontrolled (local) modes. `infiniteScroll` takes `fetchNextPage`, `hasNextPage`, and `isFetching` from TanStack Query's `useInfiniteQuery`.

Usage:

```tsx
<DataTable
  columns={columns}
  data={tableData}
  getRowId={(row) => row.uid}
  infiniteScroll={{
    fetchNextPage: onFetchNextPage,
    hasNextPage: !reachedEnd,
    isFetching: isLoadingCustomers && !reachedEnd,
  }}
  rowSelection={{ onRowSelectionChange, rowSelection }}
  sorting={{ onSortingChange, sorting }}
/>
```

## Common Mistakes

### HIGH: Incorrect semantic table nesting

Wrong -- `TableCell` inside `TableHeader`, or `TableHead` inside `TableBody`:

```tsx
// WRONG
<TableHeader>
  <TableRow>
    <TableCell>Name</TableCell>
  </TableRow>
</TableHeader>
<TableBody>
  <TableRow>
    <TableHead>John</TableHead>
  </TableRow>
</TableBody>
```

Correct -- `TableHead` in header, `TableCell` in body:

```tsx
// CORRECT
<TableHeader>
  <TableRow>
    <TableHead>Name</TableHead>
  </TableRow>
</TableHeader>
<TableBody>
  <TableRow>
    <TableCell>John</TableCell>
  </TableRow>
</TableBody>
```

`TableHead` renders `<th>` (with muted foreground, medium font weight). `TableCell` renders `<td>`. Mixing them breaks accessibility and styling.

### MEDIUM: Not using data-state for selected row styling

Wrong -- conditional className for selected rows:

```tsx
// WRONG
<TableRow className={row.getIsSelected() ? "bg-muted" : ""}>
```

Correct -- use the `data-state` attribute. The DS `TableRow` already includes `data-[state=selected]:bg-muted`:

```tsx
// CORRECT
<TableRow data-state={row.getIsSelected() && "selected"}>
```

This keeps styling consistent with the design system and avoids duplicating or conflicting with built-in styles.

### MEDIUM: Building sort UI from scratch

Wrong -- custom sort icon toggle logic with manual state:

```tsx
// WRONG
<TableHead onClick={() => setSort(col)}>
  {sortCol === col ? (sortDir === "asc" ? "^" : "v") : "-"}
</TableHead>
```

Correct -- use `Button variant="ghost"` with TanStack Table's built-in sort handler and `@loke/icons` sort icons:

```tsx
// CORRECT
<TableHead>
  <Button
    className="w-full justify-between py-1.5"
    onClick={header.column.getToggleSortingHandler()}
    size="sm"
    variant="ghost"
  >
    <span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
    <span>
      {sortDirection === "asc" && <SortAscIcon className="text-muted-foreground" />}
      {sortDirection === "desc" && <SortDescIcon className="text-muted-foreground" />}
      {!sortDirection && <ChevronsUpDownIcon className="text-muted-foreground" />}
    </span>
  </Button>
</TableHead>
```

The three icons are `SortAscIcon`, `SortDescIcon`, and `ChevronsUpDownIcon` from `@loke/icons`.

## See also

- **interactive-components/SKILL.md** -- Button, Checkbox components used in tables
- **overlay-composition/SKILL.md** -- DropdownMenu for row actions, Dialog for table actions
