---
name: table
description: "Use when building or changing a data table, grid, or list page. Covers useLayoutTable, LayoutTable, createLayoutColumnHelper, column definitions and headers, cell formatters, filter variants, sorting, row selection and bulk actions, per-row actions, pagination, footer aggregation."
metadata:
  type: convention
  library: wcz-layout
---

# Table patterns

> Mechanics (`createTableHook`, column helpers, row models, faceting, the state
> atoms) belong to TanStack Table v9 itself. List the skills shipped by
> `@tanstack/table-core` and `@tanstack/react-table` and load whichever cover the task at
> hand. This skill covers only the wcz-layout conventions on top of it.

## Rules

- For selection and actions display columns, always set `size: 50`,
  `enableResizing: false`, and `enableCellSelection: false`.
- Create the column helper once at module scope, outside the component:
  `const columnHelper = createLayoutColumnHelper<Todo>()`. Wrap the array in
  `columnHelper.columns([...])` so each column keeps its own value type.
- Build the instance with `useLayoutTable({ data, columns })` and pass it to
  `<LayoutTable table={table} />`. Rows are keyed by `row.id`, so every row object
  needs an `id`.
- Render cells with the bound components off the render prop
  (`cell.ValueCell`, `NumberCell`, `DateCell`, `DateTimeCell`, `BooleanCell`)
  instead of raw JSX. They carry the alignment, the number and date locale, and
  the noWrap behaviour. Write raw JSX only when the cell is a link or
  another interactive element.
- Set `meta.variant` for non-text filters. Omission means `"text"`.
- Give `meta.label` to any column whose `header` is not a plain string. A string
  header supplies the label automatically; a function or JSX header leaves the
  header menu and column selector without a label; the filter picker falls back to
  `column.id`. `meta` has four keys: `label`, `align`, `variant`, `options`.
- Do not enumerate `meta.options` for a free-text column. `select` and
  `multi-select` read their options from the faceted unique values, so the choices
  track the data. Pass explicit string options when labels differ from stored values or
  the UI needs a fixed list. Derive database-enum options from `enumValues`, never a
  second hand-written list.
- Translate every `header`. The table's own strings come from the app's
  `src/lib/locales/*.json` under `Layout.Table`. See the `general` skill for the
  setup step: the app owns the whole `Layout` namespace, not just `Layout.Table`.
- Wrap the table in `Fullscreen` when it is the only content on the page.
- Rows and columns virtualize by default. Pass `pagination` for page-based navigation.
  Virtualization reduces rendered DOM; it does not reduce fetched data or replace
  server-side pagination. Column virtualization
  switches off for grouped header rows and while `loading`, which is a real perf
  cliff on wide tables that use `columnHelper.group`.
- Props go on `LayoutTable`, not on a slot: `title`, `actions`, `showToolbar`,
  `loading`, `pagination`, `pageSizeOptions`, `onRowClick`, `onRowDoubleClick`,
  `sx`, and the `disableSearch` / `disableColumnFilter` / `disableColumnSelector` /
  `disableColumnMenu` escape hatches.
- Omit the second `useLayoutTable` selector when passing its result to `LayoutTable`; the
  renderer requires the full selected state.
- For bulk actions read `table.getSelectedRowIds()` and clear with
  `table.resetRowSelection()`. For per-row actions add a `display` column rendering
  `cell.ActionsCell` with a `RowAction[]`.
- A footer total needs both halves: `aggregationFn` on the column and
  `footer: ({ header }) => <header.AggregationFooter />`.

## Example

```tsx
import { Fullscreen, LayoutTable, RouterIconButton, RouterLink } from "wcz-layout/components";
import {
  createLayoutColumnHelper,
  useDialogs,
  useLayoutTable,
  useTranslation,
} from "wcz-layout/hooks";

const columnHelper = createLayoutColumnHelper<Todo>();

function RouteComponent() {
  const { t } = useTranslation();
  const { confirm, alert } = useDialogs();
  const navigate = useNavigate();
  const todos = useTodoCollection();
  const { data, isLoading } = useLiveQuery(todosQueryOptions);

  const columns = columnHelper.columns([
    columnHelper.display({
      id: "select",
      header: ({ header }) => <header.SelectAllCell scope="all" />,
      cell: ({ cell }) => <cell.SelectCell />,
      size: 50,
      enableResizing: false,
    }),
    columnHelper.accessor("name", {
      header: t("Todo.Name"),
      cell: ({ cell }) => (
        <RouterLink
          to="/todos/$id"
          params={{ id: cell.row.original.id }}
          underline="hover"
          color="inherit"
        >
          {cell.getValue()}
        </RouterLink>
      ),
      size: 260,
    }),
    columnHelper.accessor("assignee", {
      header: t("Todo.Assignee"),
      cell: ({ cell }) => <cell.ValueCell />,
      // No options: the filter builds them from the faceted unique values.
      meta: { variant: "multi-select" },
      size: 180,
    }),
    columnHelper.accessor("priority", {
      header: t("Todo.Priority"),
      cell: ({ cell }) => <cell.NumberCell />,
      footer: ({ header }) => <header.AggregationFooter options={{ maximumFractionDigits: 1 }} />,
      aggregationFn: "mean",
      meta: { variant: "number", align: "right" },
      size: 120,
    }),
    columnHelper.accessor("starred", {
      header: t("Todo.Starred"),
      cell: ({ cell }) => <cell.BooleanCell />,
      meta: { variant: "boolean", align: "center" },
      size: 120,
    }),
    columnHelper.accessor("createdAt", {
      header: t("Todo.CreatedAt"),
      cell: ({ cell }) => <cell.DateTimeCell />,
      meta: { variant: "date" },
      size: 190,
    }),
  ]);

  const table = useLayoutTable({ data, columns });
  const selectedIds = table.getSelectedRowIds();

  const handleOnDelete = createOptimisticAction<Array<string>>({
    onMutate: (ids) => {
      ids.forEach((id) => {
        todos.delete(id);
      });
    },
    mutationFn: async (ids) => {
      await deleteTodos({ data: ids });
      await todos.utils.refetch({ throwOnError: true });
    },
  });

  return (
    <Fullscreen>
      <LayoutTable
        table={table}
        loading={isLoading}
        showToolbar
        title={t("Todo.Todos")}
        onRowDoubleClick={(row) => navigate({ to: "/todos/$id", params: { id: row.id } })}
        actions={
          <>
            <Tooltip title={t("Create")}>
              <RouterIconButton to="/todos/create">
                <Add fontSize="small" />
              </RouterIconButton>
            </Tooltip>

            {selectedIds.length === 1 && (
              <Tooltip title={t("Edit")}>
                <RouterIconButton to="/todos/edit/$id" params={{ id: selectedIds[0] }}>
                  <Edit fontSize="small" />
                </RouterIconButton>
              </Tooltip>
            )}

            {selectedIds.length > 0 && (
              <Tooltip title={t("Delete")}>
                <IconButton
                  onClick={async () => {
                    const confirmed = await confirm(
                      t("DeleteConfirmation", { count: selectedIds.length }),
                    );
                    if (!confirmed) return;

                    try {
                      const transaction = handleOnDelete(selectedIds);
                      await transaction.isPersisted.promise;
                      table.resetRowSelection();
                    } catch (error) {
                      if (error instanceof Error) await alert(error.message);
                    }
                  }}
                >
                  <Badge
                    badgeContent={selectedIds.length}
                    invisible={selectedIds.length <= 1}
                    color="error"
                  >
                    <Delete fontSize="small" />
                  </Badge>
                </IconButton>
              </Tooltip>
            )}
          </>
        }
      />
    </Fullscreen>
  );
}
```

### Per-row actions

```tsx
columnHelper.display({
  id: "actions",
  cell: ({ cell }) => (
    <cell.ActionsCell
      actions={[
        {
          label: t("Edit"),
          icon: <Edit fontSize="small" />,
          onClick: (row) => navigate({ to: "/todos/edit/$id", params: { id: row.id } }),
        },
        {
          label: t("Delete"),
          icon: <Delete fontSize="small" />,
          divider: true,
          onClick: (row) => remove(row.id),
        },
      ]}
    />
  ),
  size: 60,
  enableResizing: false,
}),
```

### Other bound components

On the header render prop: `SelectAllCell`, `ExpandAllCell`, `AggregationFooter`,
`HeaderMenu`, `HeaderSortLabel`, `HeaderResizeHandle`. On the cell one:
`SelectCell`, `ExpandCell`, `GroupCell`, `PinRowCell`, `ActionsCell` and the
formatter cells. `LayoutTable` already places the menu, the sort label and the
resize handle, so reach for those three only when building a table by hand.

### Custom cells and headers

The render prop only exposes the components registered in the library. To write your
own, read the context directly inside a plain component: `useCellContext<TValue>()`,
`useHeaderContext()` or `useTableContext()` from `wcz-layout/hooks`.
`layoutTableFeatures` and the `LayoutTableFeatures` type are the feature set
`useLayoutTable` installs — you need them only to type a hand-built table instance.

## Common Mistakes

### CRITICAL: Writing TanStack Table v8 column definitions

Wrong: `accessorKey: "name"`, `cell: (info) => info.getValue()`, `getCoreRowModel:
getCoreRowModel()`.

Correct: `columnHelper.accessor("name", { cell: ({ cell }) => <cell.ValueCell /> })`
inside `columnHelper.columns([...])`, and no row models at all.

This is v9. v8 column defs either type-error inside `columnHelper.columns()` or
silently no-op, and row models are gone.

### HIGH: Adding your own virtualizer or pager

Wrong: wrapping rows in `useVirtualizer` from `@tanstack/react-virtual`, or bolting on
a `TablePagination`.

Correct: render `<LayoutTable table={table} />` and pass `pagination` if the user wants
a pager.

`LayoutTable` already virtualizes rows and columns. A second virtualizer fights the
first over scroll position.

Use only `LayoutTable`'s `pagination` prop for its pager.
