Add row selection
Use this guide when users need to act on more than one row at a time.
When to add selection
Row selection is for work, not decoration.
Add it when users need to:
- archive, assign, export, or update several rows at once
- keep a working set selected while previewing or opening individual rows
- apply a bulk workflow to either visible rows or the whole filtered result
If the table does not yet have a concrete selection-driven action, skip selection for now.
Enable selection
Selection is configured with the selection prop.
<Datatable
tableKey="customers"
data={rows}
columns={columns}
getRowId={(row) => row.id}
selection={{
enabled: true,
showCheckboxOnHover: false,
}}
/>The current implementation is multi-select, so you do not need to set a separate mode.
When selection is enabled, the table adds a synthetic checkbox column at the start of the grid. That column is part of the interaction model, but not part of your saved user column order.
[!NOTE] Screenshot placeholder: selection column and selected rows visible in a realistic grid, with one active row that is not the same thing as the selected set.
Control which rows can be selected
Use getRowCanSelect when the next action only applies to some rows.
<Datatable
tableKey="customers"
data={rows}
columns={columns}
getRowId={(row) => row.id}
selection={{
enabled: true,
getRowCanSelect: (row) => row.permissions.canArchive,
}}
/>Tie the predicate to the action the user is about to run.
Examples:
- only rows the current user can archive
- only invoices that are still unpaid
- only records that are safe to retry
The table uses that predicate both when rendering row checkboxes and when calculating how many rows are actually selectable in the current filtered result.
Checkbox visibility
showCheckboxOnHover controls whether unselected rows keep their checkbox hidden until the row is hovered.
selection={{
enabled: true,
showCheckboxOnHover: true,
}}Use hover-only checkboxes when selection is secondary to the main table workflow.
Show checkboxes all the time when:
- bulk work is a primary use case
- the table needs to feel obviously actionable
- users might otherwise miss that multi-select exists
[!NOTE] Screenshot placeholder: side-by-side or annotated comparison of hover-only checkboxes versus always-visible checkboxes, showing when each selection style feels right.
Select-all scope
The selection model supports two different scopes:
- explicit selection of concrete row IDs
- all-matching selection for the current online query
If you only need visible or loaded-row selection, stop here. If users need actions like "export every filtered customer," enable allowSelectAllMatching.
<Datatable
tableKey="customers"
online={{
mode: "pagination",
queryKey: ["customers", "listOnline"],
query: listCustomers,
}}
columns={columns}
getRowId={(row) => row.id}
selection={{
enabled: true,
allowSelectAllMatching: true,
}}
/>With that option enabled, the header checkbox can switch from explicit IDs to an allMatching descriptor built from the current table query.
The two selection payloads
The action layer receives a DataTableSelectionDescriptor.
type DataTableSelectionDescriptor =
| {
kind: "explicit"
ids: string[]
}
| {
kind: "allMatching"
query: OnlineQueryStateInput
includedIds: string[]
excludedIds: string[]
totalMatchingRows: number
}Use explicit when the action should target only the rows the browser has identified directly.
Use allMatching when the user means "everything matching the current filters, sorting, search, and grouping," even if many of those rows are not currently mounted.
That distinction keeps large online bulk workflows honest.
All-matching action copy
When allowSelectAllMatching is enabled, the action text must say what will actually happen.
Good examples:
Export all 2,400 matching customersArchive 138 filtered issuesAssign selected rows
Bad examples:
RunApplyExport selectedwhen the action really targets all matching rows
The point is to prevent users from thinking they selected only what they can currently see.
[!NOTE] Screenshot placeholder: all-matching selection banner or copy state such as “Export all 2,400 matching customers,” making the scope larger than the visible viewport unmistakable.
Resolve online selections on the server
For all-matching selections, do not expect the browser to send every target row ID.
Use the saved query plus the exclusion list.
if (selection.kind === "explicit") {
rows = await db.select().from(customers).where(inArray(customers.id, selection.ids))
} else {
rows = await applyOnlineQuery(db.select().from(customers), selection.query)
rows = rows.filter((row) => !selection.excludedIds.includes(row.id))
}That lets the server resolve the real target set without forcing the client to load every row first.
Selection resets when the query meaningfully changes
Selection is tied to the current working result set.
When filters, search, sorting, or grouping change enough to alter that result set, the table clears selection instead of pretending the old selection still means the same thing.
A selected set from one query should not silently carry into a different query and trigger the wrong bulk action.
Verify row selection before you move on
Before you continue, confirm that:
- selection exists because a real multi-row workflow needs it
- active row, preview, and selection are visually distinct
getRowCanSelectblocks rows that should stay out of the workflow- header select-all behaves the right way for your product surface
- all-matching copy states scope clearly when rows outside the viewport are included
- server actions can interpret
explicitversusallMatchingcorrectly - changing the query clears stale selection instead of carrying it into a different result set