Row actions and selection

Row action menus and quick bars, row selection, bulk actions, and selecting every result that matches the current query.

Row actions

RowActions renders a row's actions either way:

{
  key: 'actions', header: '', sticky: 'right', width: '7rem', exportable: false,
  render: (item, i) => (
    <RowActions
      item={item}
      index={i}
      variant='inline'          // icon buttons in the cell; 'menu' (default) collapses them behind •••
      maxInline={3}             // past this the overflow folds into a trailing •••
      actions={[
        { label: 'Ver', icon: <Eye size={16} />, onClick: open },
        { label: 'Descargar', icon: <FileDown size={16} />, onClick: download },
        { label: 'Cancelar', icon: <X size={16} />, danger: true, onClick: cancel,
          disabled: item => item.canceled && 'Ya está cancelada' },
      ]}
    />
  ),
}

'inline' is one click instead of two, for the actions an operator uses on every row. Each button is icon-only with its label as the accessible name and the tooltip, so it costs a fixed width no matter how long the label is — an action without an icon falls back to rendering the label, which widens the column and usually means it belongs in the menu. disabled returns the reason, which becomes the tooltip.

loading covers the action that takes a server round trip — preparing a download, sending a mail. It swaps the icon for a spinner and blocks a second click, which is what separates "nothing happened" from "working on it":

{ label: 'Descargar', icon: <FileDown size={16} />, onClick: download,
  loading: item => downloading.has(item.id) }

Quick actions. Mark an action quick (it needs an icon) and, on hover-capable devices, hovering the ••• slides it out to the left as an icon button on the row's own line — one click for the action an operator reaches for on every row. It is a shortcut, not the only path: the action still appears in the ••• menu, so on touch — where hover does not exist — nothing is lost, it just lives one tap deeper.

The bar is forgiving by design: it stays interactive through a short grace period after the pointer leaves, so aiming across the gap between the ••• and a button never loses the target; it hides the moment one of its actions runs, so an action that opens a dialog does not leave the bar floating; and revealing one row's bar dismisses the previous row's instantly, so sweeping down the column leaves no trail.

By default the bar reveals when the pointer reaches the ••• cluster. Set rowActionsQuickReveal: 'row' in the config (or quickReveal='row' on a hand-rolled RowActions) to reveal it from anywhere on the row — one less aiming step:

defineListConfig({ rowActions, rowActionsQuickReveal: 'row' /* … */ })

Both the row ••• menu and the toolbar overflow follow the WAI-ARIA menu keyboard pattern: ArrowUp/ArrowDown move through the items (wrapping), Home/End jump to the extremes, Escape closes.

Grouped menu. Give actions a group and the ••• menu clusters them under that title, separated by dividers — the Stripe-style sectioned menu. Ungrouped actions render first, untitled; groups follow in first-appearance order. The title is user-facing text, so pass it already localized:

rowActions: [
	{
		label: 'Download PDF',
		icon: <FileDown size={16} />,
		quick: true,
		group: 'Actions',
		onClick: download,
	},
	{
		label: 'Edit invoice',
		icon: <Pencil size={16} />,
		quick: true,
		group: 'Actions',
		onClick: edit,
	},
	{ label: 'Copy invoice ID', group: 'Actions', onClick: copyId },
	{ label: 'View customer', group: 'Connections', onClick: viewCustomer },
]

The same group field exists on toolbar actions: on small screens, where they fold into the toolbar's ••• overflow, the menu renders the same titled sections.

Pagination variants. 'sticky' floats in the content flow and needs no viewport offset — which is why it is now the default. 'fixed' pins to the viewport and, in an app with a fixed sidebar, needs paginationOffsetLeft so the bar clears it:

<ListView
	config={config}
	paginationVariant='fixed'
	paginationOffsetLeft='var(--app-sidebar-w)'
/>

'inline' renders the bar statically at the end of its container. Inside a flex-column card it settles at the bottom even when the list is short or empty — the case that previously required position: static !important.

Pinned columns. Give a column sticky: 'left' | 'right' plus a width and it stays visible while the table scrolls sideways. Use 'right' for an actions column so a row can be acted on without scrolling to the end:

{ key: 'actions', header: '', sticky: 'right', width: '64px', exportable: false, render: rowActions }

Several columns may pin to the same edge; their offsets stack in column order. A pinned column without a width is left unpinned rather than placed at a wrong offset.

Row selection & bulk actions

Enable checkboxes and a selection bar with selection. Selection is key-based, survives pagination, and clears when the dataset changes (search/filters/sort/refresh) so a stale selection can't leak:

import { Star, Trash2 } from 'lucide-react'

defineListConfig<Product>({
	getItemKey: p => p.id, // required for stable selection
	selection: {
		actions: [
			{
				label: 'Feature',
				icon: <Star size={16} />,
				onClick: rows => featureMany(rows),
			},
			{
				label: 'Delete',
				icon: <Trash2 size={16} />,
				variant: 'danger',
				// Each action gets the selected rows + helpers: { selectedKeys, clear }.
				onClick: async (rows, { selectedKeys, clear }) => {
					await deleteMany(selectedKeys) // ids, from getItemKey
					clear() // drop the selection after a successful bulk action
				},
			},
		],
		onSelectionChange: rows => setSelected(rows),
	},
})

What the selection gives you. Selection is keyed by getItemKey, so each entry has an id (the key) and the full row object:

  • A bulk action's onClick(selected, { selectedKeys, clear }) receives selected (the T[] rows — even ones from other pages) and selectedKeys (their ids from getItemKey). Use the ids for a DELETE … WHERE id IN (…) and clear() to reset afterwards.
  • onSelectionChange(selected, details) fires whenever the set changes: selected is the same T[], and details carries mode, keys, excludedKeys and the resolved count — the all-matching mode a bare array cannot express.
  • For full control, call the exported useRowSelection hook directly (selectedKeys, selectedItems, isSelected, toggle, toggleMany, clear).

Other notes:

  • The table gains a separated checkbox column with a header select-all-this-page (indeterminate when only some are selected).
  • Rows are tracked by key, so selecting across pages keeps the full row objects for your bulk handler — no React Query required.
  • clearOnDataChange: false keeps the selection across filter/sort changes (the default clears it).
  • When export is enabled, the selection bar also shows Export selected (disable with showExport: false).
  • In cards view, ctx.selection (isSelected/toggle) lets a custom card render its own checkbox.

Locking or gating the checkboxes. disabled: true keeps the column but refuses every toggle, so it reads as a read-only indicator — use it when picking rows is meaningless in the current mode, since a column that disappears and comes back moves the table under the reader, and hiding it loses the state it was showing. The bulk bar, "export selected" and the selection shortcuts go with it. selectableRow(item, key) gates one row at a time; the page-header checkbox then covers only the selectable rows.

selection: {
	disabled: mode === 'review', // read-only indicator
	selectableRow: row => row.status !== 'locked',
}

Both gates hold on every write path — the row checkbox, the page header, the card checkbox, the keyboard shortcuts and the published controller. The one thing selectableRow cannot reach is select all N matching: that selection is virtual and resolved server-side from the query, so rejected rows are still included. Pair the two only when the gate is advisory, or set allowSelectAllMatching: false.

Driving the selection from your own UI. controllerRef publishes the live selection API — mode, selectedKeys, excludedKeys, selectedItems, selectedCount, query, pageEntries, plus toggle / setSelected / toggleMany / selectAllMatching / clear — so a button outside the list can read and drive the checked set. Pass a plain ref object; listkit nulls it on unmount.

const controllerRef = useRef<SelectionController<Invoice> | null>(null)

selection: {
	controllerRef
}
// anywhere else:
controllerRef.current?.toggleMany(controllerRef.current.pageEntries, true)

preselectLoadedRows: true inverts the default: every newly loaded row arrives checked, so the scope the user filtered to is the selection and unchecking is the exception. A key the user unchecks stays unchecked — only never-seen keys auto-select — and the seen set resets with the dataset. Pair it with an adapter whose key folds in every external scope, so a scope change never preselects the previous scope's rows into the new one.

Selecting every matching result

Selecting the whole page offers to escalate to all N matching results — the Gmail/Stripe pattern. It is a virtual selection: listkit does not load the other pages, it records the current search/filters plus whatever you untick afterwards.

selection: {
	allowSelectAllMatching: false, // opt out; default true
}

What a bulk action receives depends on the mode, and the difference matters:

helpers.modeselected / selectedKeysResolve against
'explicit'every selected rowthe keys
'all-matching'only the rows this client loadedhelpers.query minus helpers.excludedKeys

An 'all-matching' action must run against the query, not the row array — the rows on other pages were never fetched, so keying off selectedKeys would touch the current page and silently spare every other one:

onClick: async (rows, { selectedKeys, mode, query, excludedKeys, clear }) => {
	if (mode === 'all-matching') await archiveByQuery(query, excludedKeys)
	else await archiveMany(selectedKeys)
	clear()
}

The server side resolves that query with the same builders the list uses — buildMongoFilter / buildSqlFilter — so the rows an action touches are exactly the rows the user saw. Export works the same way: the dialog's all scope hands the resolver the query and the exclusions.

Posting a selection to the server. For a bulk mutation, send a SelectionDescriptor rather than a flat id list — the mutation counterpart of the export request. toSelectionDescriptor builds one from any selection snapshot (the details argument, a controller, or a bulk action's helpers), selectionDescriptorToBody serializes it for a POST, parseSelectionDescriptor (from /query) validates it server-side, and resolveSelectionFilter (from /mongoose) turns it into the filter the write runs on:

// client
const body = selectionDescriptorToBody(
	toSelectionDescriptor(controllerRef.current!)
)
await fetch('/api/invoices/archive', {
	method: 'POST',
	body: JSON.stringify(body),
})

// server
const descriptor = parseSelectionDescriptor(req.body)
if (!descriptor) return res.status(400).end()
const filter = await resolveSelectionFilter({
	descriptor,
	fields: maps.main,
	baseFilter: { tenant: req.tenant }, // auth scope — always
})
if (filter) await Invoice.updateMany(filter, { $set: { archived: true } })

Both halves fail closed. parseSelectionDescriptor returns null for anything structurally wrong — including a body with no nested query, which under an 'all' scope would otherwise resolve to every row the base filter allows — and resolveSelectionFilter returns null for an empty selection, so the caller no-ops instead of handing updateMany a match-everything filter.

Exporting that selection needs a way to reach the rows. With in-memory data or an export resolve, the dialog's Selected scope covers all 12,000; without one, it is disabled with an explanation rather than hidden, and the one-click "Export selected" is withheld — a file holding the loaded page while the bar reads "12,000 selected" is worse than no file.

On this page