---
id: "packages/active-grid/selection"
title: "Selection"
description: "Row selection, select-all in server mode without loading every row, and the selection model."
url: "/packages/active-grid/selection"
locale: "en"
revision: "d1727f1fc85c0ec3"
---

# Selection

> [!NOTE]
> **The package's own guide**
>
> This guide is the package's `docs/guides/selection.md`, included unchanged
> from the package as it is in Foundation. Links to the package's internal
> design documents keep their text.

Row selection in `@workspace/active-grid` works out of the box with the
`getSelectionColumn` helper. For **server-mode** grids with large datasets the
grid adds a second selection mechanism: "select all 5000 rows" without
materialising all of them in client state. This guide documents both modes and
the `SelectionModel` API consumers use to pass the selection to their actions.

## Two modes

| Mode | When active | State shape |
| --- | --- | --- |
| **Inclusive** | Default. The user ticks rows. | `{ rowIds: ["id-1", "id-3"] }` — the IDs of the selected rows |
| **Exclusive** | The user accepts the "Select all N rows" offer | `{ excludedRowIds: ["id-7"], totalCount: 5000, query }` — every row EXCEPT these IDs, plus the query that gives "all" meaning |

Inclusive is always available. Exclusive exists only in server mode with
multi-row selection, and only once the user has taken the dataset-wide offer.
The banner makes that offer when the whole page is selected and the dataset
holds more rows; the selection action bar makes it when rows of the page are
selected, the dataset holds more, and at least one action supports it.

## API — `SelectionModel`

```ts
export type SelectionModel =
  | {mode: 'inclusive'; rowIds: string[]}
  | {
      mode: 'exclusive';
      excludedRowIds: string[];
      totalCount: number;
      query: SelectionQuery;
    };
```

Consumers receive it through `onSelectionModelChange`:

```tsx
<ActiveGrid
  mode="server"
  fetchFn={fetchFn}
  columns={columns}
  onSelectionModelChange={(model) => {
    if (model.mode === 'inclusive') {
      console.log('Selected IDs:', model.rowIds);
    } else {
      console.log('Excluded IDs:', model.excludedRowIds);
      console.log('Total in dataset:', model.totalCount);
    }
  }}
/>
```

## Server-mode flow

```
User ticks the header checkbox: the current page is selected
  ↓
The grid offers "Select all 5000 rows" (banner or selection action bar)
  ↓
User takes the offer → the grid switches to mode: "exclusive"
  ↓
The header checkbox shows checked; the banner reads "All 5000 rows are
selected." and the action bar "All 5000 selected"
  ↓
User deselects a row → that ID goes to excludedRowIds
  ↓
onSelectionModelChange fires on every change of the model
  ↓
The consumer action uses the model (see "Executing an exclusive selection")
```

The exclusive selection survives page navigation: when the user moves to page
2 and deselects a row there, that ID goes to `excludedRowIds`. The total count
stays that of the whole dataset, not of the visible page. Deselecting every row
of the dataset returns the grid to an empty inclusive selection.

## The selection action bar

A grid that declares `selectionActions` draws its own bulk bar above the body:

```text
[ 10 selected ] [ Actions v ]                    [ Select all 32 rows ]
```

```tsx
<ActiveGrid
  mode="server"
  enableRowSelection
  selectionActions={[
    {
      id: 'refresh',
      label: t('records.bulk.refresh'),
      icon: RefreshCw,
      supportsDatasetWideSelection: false,
      onAction: handleRefresh,
    },
    {
      id: 'delete',
      label: t('records.actions.bulkDelete'),
      icon: Trash2,
      tone: 'danger',
      separatorBefore: true,
      supportsDatasetWideSelection: false,
      onAction: () => setShowConfirm(true),
    },
  ]}
/>
```

**The shape never varies with the number of actions.** One action lives in the
same menu as five. A bar that shows a bare button at one action and a menu at
two teaches the reader nothing: the control moves, its label changes, and the
same task is reached two different ways on two screens.

**What the grid owns:** the bar, the count, the fixed Actions menu, keyboard
behaviour, the default and destructive presentations, the busy state (an async
handler holds the whole menu until it settles, so one action cannot be started
twice and two cannot overlap), and suppressing the footer's selection counter
so the same number is never stated twice.

**What the consumer owns:** which actions exist, their labels, their icons,
what they do, and confirmation copy. The grid holds no business meaning.

**What is deliberately absent:**

| Not in the bar | Where it lives instead |
| --- | --- |
| a "Clear selection" item | the header checkbox, which already does it |
| an "Apply" button | a menu item IS the instruction |
| a `ReactNode` slot | `GridSelectionAction`, declarative |
| a disabled item for a missing right | absence — leave it out of the array |

Without `selectionActions` the grid keeps the original select-all banner, so
existing consumers are unchanged.

## Executing an exclusive selection

An exclusive model says "everything except these ids", and *everything* is only
meaningful together with the query that produced it. That query travels with
the model as `SelectionQuery`:

```typescript
if (selection.mode === 'exclusive') {
  selection.query.filters; // the grid's own column filters
  selection.query.search; // the grid's own search term
  selection.query.externalKey; // your `serverQueryKey`, verbatim
}
```

**There is no mutation resolver for this yet.** The grid carries the query and
never interprets it, and no shared server-side resolver turns a
`SelectionQuery` into a validated mutation scope today: the consumer must
translate the `SelectionQuery` itself, against its own closed schema. That
resolver is open work, tracked as R-33 in `../ROADMAP.md`.
Until a consumer has written that translation, the honest declaration is
`supportsDatasetWideSelection: false` (below).

Three rules for the server side, and they are not negotiable:

1. **Never trust `totalCount`.** It is a display number that arrived from the
   client. The count of what was actually done comes from the query that did
   the work.
2. **Re-derive and re-authorize the query server-side.** The client sends the
   scope it was looking at; the server resolves the caller's visibility again
   and validates the scope against a closed schema. A raw `where` never crosses
   the wire.
3. **Apply the exclusions server-side** (`id: {notIn: excludedRowIds}`) inside
   that same query, never by filtering a client-side list.

The shape of such an action, where `translateSelectionScope` stands for the
consumer's own translation (it is not a function this package or any shared
package provides):

```ts
'use server';

export async function bulkArchiveByScopeAction(
  scope: unknown,
  excludedIds: string[],
  csrfToken: string,
) {
  const auth = await performAuthCheckWithCsrf(PERMISSIONS.X.ARCHIVE, csrfToken);
  if (!auth.success) return auth;

  // Consumer-written: re-validates the scope against a closed schema and
  // resolves the caller's own visibility again, on the server.
  const where = await translateSelectionScope(
    auth.data,
    ScopeSchema.parse(scope),
  );

  const result = await db.invoice.updateMany({
    where: {AND: [where, {id: {notIn: excludedIds}}]},
    data: {archived: true},
  });
  return {success: true, data: {archived: result.count}};
}
```

**When the action cannot do this, say so in the declaration.** Set
`supportsDatasetWideSelection: false` and the grid stops offering dataset-wide
select-all altogether (when no action supports it) and hides the action from
the menu while such a selection is active. That is the whole point: the UI
never communicates a selection the action will not honour.

Reasons an action legitimately cannot:

- its server side takes an explicit id list;
- the work is per-row and unbounded (an object-store delete, an image
  re-render) with no background carrier to run it on;
- the mutation cannot express the query as a `where` at all.

## Imperative selection (advanced)

Keep the latest model from `onSelectionModelChange` in host state or a ref when
a separate action needs to read it. `GridImperativeHandle` exposes
`resetSelection()`, not a selection-model getter.

## UI affordances

The grid shows:

- **Header checkbox**: it selects and deselects the rows of the CURRENT PAGE,
  and is therefore also the way to clear a selection — which is why the action
  bar carries no clear button of its own. For assistive technology it has three
  states: `aria-checked` is `false` (none), `mixed` (part of the page) or
  `true` (the whole page). **Visually it shows two:** a partly selected page
  looks the same as a fully selected one, and a click on it clears the page.
  The partial mark and the click behaviour are open (TD-45 in
  `../ROADMAP.md`). During an exclusive selection the header
  checkbox is checked, and a click clears the whole selection. With
  `enableMultiRowSelection={false}` there is no header checkbox.
- **Selection action bar** (with `selectionActions`, above the body): the
  count, the fixed Actions menu, and on the right the dataset-wide offer when
  an action can execute it. See above.
- **Banner row** (without `selectionActions`, the original behaviour): the
  offer "Select all 5000 rows", or "All 5000 rows are selected." with a clear
  link.
- **Body checkbox per row**: visually the same in both modes, but the click
  differs — in inclusive mode it adds to `rowIds`, in exclusive mode it moves
  the row to `excludedRowIds`.

Both bars are styled by the package stylesheet (`styles/chrome.css`) from the
grid's role tokens: `--grid-separator` for the bottom edge of the action bar,
`--grid-primary` at 10% for the banner row. A host changes them through those
tokens, not through its own classes.

## Persistence across reloads

Selection state is **not** persisted in localStorage or the URL. When the user
reloads the page the selection is gone. The reason: a selection of 5000 rows on
a filtered dataset depends on its context, and persisting it would risk stale
state after server-side mutations.

For selections that must persist (for example a wishlist or a multi-step
wizard): keep the chosen IDs in the host through `onSelectionModelChange`. The
component has no controlled selection props named `rowSelectionState` or
`onRowSelectionStateChange`; do not confuse the public kernel command
`grid.setRowSelection()` with component props.

## Cross-refs

- `api/grid-props.md` — `selectionActions`, `GridSelectionAction`,
  `SelectionQuery`, `onSelectionModelChange`, `enableMultiRowSelection`
- `guides/server-mode.md` — server-mode basics
- ADR-026
  in `DECISIONS.md` — the exclusive model, its `SelectionQuery`, and why an
  action declares which selection shape it can execute
