Skip to content
View as Markdown

Multi Select ​

When to Use ​

  • ANY list page where rows can be acted on in bulk (archive, assign, export, approve, delete)
  • The list is a DataTable — selection and the bulk-action bar are built in; you only declare the actions
  • Some actions apply to only part of a selection (approve only pending rows, delete only archived ones)

Layout ​

Pass rowSelection.actions to useDataTable. The moment one row is selected, DataTable.Footer becomes the bulk-action bar: a tinted strip with the selection count, the actions, and Clear, with pagination kept on the right. It turns back into the normal footer when the selection empties. In <Layout fill> the footer is already pinned; on a page that scrolls, the bar sticks to the bottom of the viewport until the end of the table is reached.

+--------------------------------------------------------------------+
| Layout.Header   title                                  [Create]    |
+--------------------------------------------------------------------+
| Layout.Column                                                      |
|  DataTable.Root                                                    |
|   Toolbar   [Filters]                                  [Columns]   |
|   [x] | Col   | Col   | Col   | Col                                |
|   [x] | row   | row   | row   | row                                |
|   [ ] | row   | row   | row   | row                                |
|   [x] | row   | row   | row   | row                                |
|   DataTable.Footer, while rows are selected:                       |
|   3 of 240 selected | [Confirm] [Export] [⋯] | Clear       ‹ 1/10 › |
+--------------------------------------------------------------------+

Page Implementation ​

tsx
function InteractionMultiSelect() {
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
  const [pendingCancel, setPendingCancel] = useState<PendingCancel | null>(null);
  const [cancelling, setCancelling] = useState(false);
  const [cancelError, setCancelError] = useState<string | null>(null);

  const removeCompletedIds = (ids: string[]) => {
    const completed = new Set(ids);
    setSelectedIds((current) => current.filter((id) => !completed.has(id)));
  };

  const actions: SelectionAction[] = [
    {
      id: "confirm",
      label: "Confirm",
      // The operation returns actual successes; never clear ineligible or newer selections.
      onClick: async (ids) => removeCompletedIds(await confirmOrders(ids)),
    },
    {
      id: "export",
      label: "Export",
      onClick: (ids) => exportOrders(ids),
    },
    {
      id: "assign",
      label: "Assign owner",
      onClick: async (ids) => window.alert(`Assigning ${ids.length} order(s)`),
    },
    {
      // 4th action: lands in the More actions menu.
      id: "cancel",
      label: "Cancel",
      variant: "destructive",
      onClick: (ids, { run }) => {
        setCancelError(null);
        setPendingCancel({ ids: [...ids], run });
      },
    },
  ];

  // In a real app, fetch eligibility for these snapshot ids before showing this explanation.
  const cancellableCount = mockOrders.filter(
    (order) => pendingCancel?.ids.includes(order.id) && order.status !== "Shipped",
  ).length;

  const table = useDataTable({
    columns,
    data: { rows: mockOrders, total: mockOrders.length },
    rowSelection: { selectedIds, onChange: setSelectedIds, actions },
  });

  return (
    <>
      <DataTable.Root value={table}>
        <DataTable.Table />
        <DataTable.Footer>
          <DataTable.Pagination />
        </DataTable.Footer>
      </DataTable.Root>

      <Dialog.Root
        open={pendingCancel !== null}
        onOpenChange={(open) => {
          if (!open && !cancelling) setPendingCancel(null);
        }}
      >
        <Dialog.Content>
          <Dialog.Header>
            <Dialog.Title>Cancel selected orders?</Dialog.Title>
            <Dialog.Description>
              {cancellableCount} of {pendingCancel?.ids.length ?? 0} selected orders can be
              cancelled; shipped orders are excluded. Cancelled orders can&apos;t be reopened.
            </Dialog.Description>
          </Dialog.Header>
          {cancelError && <p role="alert">{cancelError}</p>}
          <Dialog.Footer>
            <Dialog.Close render={<Button variant="outline" disabled={cancelling} />}>
              Keep orders
            </Dialog.Close>
            <Button
              variant="destructive"
              disabled={cancelling || cancellableCount === 0}
              onClick={async () => {
                if (!pendingCancel || cancelling) return;
                setCancelling(true);
                setCancelError(null);
                try {
                  const cancelledIds = await pendingCancel.run(cancelOrders(pendingCancel.ids));
                  removeCompletedIds(cancelledIds);
                  setPendingCancel(null);
                } catch {
                  setCancelError("Cancellation failed. Please try again.");
                } finally {
                  setCancelling(false);
                }
              }}
            >
              {cancelling ? "Cancelling…" : "Cancel orders"}
            </Button>
          </Dialog.Footer>
        </Dialog.Content>
      </Dialog.Root>
    </>
  );
}

Constraints ​

  • Declare bulk actions as the non-generic SelectionAction[] in useDataTable's rowSelection.actions — do NOT hand-build the bar, a floating panel, or checkboxes; the footer renders the selection count, actions, overflow menu, and Clear. Providing rowSelection enables checkboxes even when actions is omitted or empty; the bar requires non-empty actions and selected ids
  • Keep DataTable.Footer in the tree — it is where the bar renders
  • Selection stores IDs only, not row objects. onClick receives a click-time copy of all selected ids (string[]), including unloaded ids; fetch any records required for the operation in the consumer's ID-based workflow
  • The consumer owns eligibility checks, required fetching, confirmation UI, and execution; backend validation remains authoritative. Selection actions have no row-based canApply, automatic eligible count, or eligibility-based disabled state
  • Return the promise from onClick for async work: the bar disables its actions and shows a spinner while it runs. Success and rejection both leave selection unchanged; there is no keepSelection option. Rejections are logged; show errors in the consumer UI
  • Use controlled rowSelection: { selectedIds, onChange } to remove only ids actually changed, with a functional update against the current selection. Preserve ineligible ids and ids selected after the operation started; cancellation or failure does not clear selection
  • At most 3 actions render inline, in array order; the 4th onward move to the More actions menu — order by frequency and put a destructive action last
  • Destructive bulk actions MUST open an interaction/confirm dialog from onClick. Optionally keep the run helper and pass it the later request (await run(deleteRows(ids))) for pending tracking only. clearSelection explicitly clears every page when that is intentional
  • Share only presentation fields (id, label, icon, variant) as a non-generic DataTableAction. RowAction<TRow> retains canApply for its concrete row; bulk eligibility belongs in the ID-based consumer workflow
  • Selection persists across pagination, filter, and sort changes — do NOT clear it on those

Anti-patterns ​

  • Hand-rolled selection (useState<Set>, raw <input type="checkbox">, Table.Root) when the list is a DataTable
  • A floating or fixed-position action bar over the table instead of the footer bar
  • Placing bulk-action buttons in the page header or the table toolbar
  • Treating the currently loaded rows as the full selection or deriving bulk eligibility from incomplete row data
  • Clearing the whole selection after partial success, or overwriting it with the click-time snapshot and losing newer selections
  • Fire-and-forget async onClick that doesn't return its promise or use run — users can fire it twice
  • Firing destructive bulk actions without an interaction/confirm step
  • Per-row Menu actions as a substitute for bulk actions when selection > 0