Skip to content

wcz-layout

table

268 linesSource

Use when building or changing a data table. Covers useLayoutTable, LayoutTable, createLayoutColumnHelper, column definitions and headers, cell formatters, filter variants, sorting, row selection and bulk actions, per-row actions, pagination, footer aggregation.

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

  • 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.
  • For selection and actions display columns, always set size: 50, enableResizing: false, and enableCellSelection: false. An actions column that shows more than one button needs about 40px more per extra button.
  • 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.
  • 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 five keys: label, align, variant, options, flex.
  • meta.flex makes a column grow or shrink, in proportion like CSS flex, so the table fills its container exactly with no horizontal scroll. size is the starting width; it shrinks no further than minSize (default 20), and when that still cannot fit the columns keep their sizes and the table scrolls. Resizing the column pins it to a fixed width.
  • Wrap the table in Fullscreen when it is the only content on the page.
  • Props: title, actions, showToolbar, loading, pagination, pageSizeOptions, onRowClick, onRowDoubleClick, sx, and the disableSearch / disableColumnFilter / disableColumnSelector / disableColumnMenu escape hatches.
  • Rows and columns virtualize by default. Rows are measured, so a cell may grow to several lines: give its content whiteSpace: "normal" (cells are one ellipsized line otherwise).
  • Set meta.variant for non-text filters. Omission means "text".
  • 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.

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,
      enableCellSelection: 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" />,
          showInMenu: true,
          onClick: (row) => remove(row.id),
        },
      ]}
    />
  ),
  size: 50,
  enableResizing: false,
  enableCellSelection: 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.