Server selection: select all, minus these
The header checkbox selects every row the filter matches, loaded or not, and the selection becomes a rule: all except these ids, or per group under grouping. The panel shows getSelectionState() live, Save and Restore round-trip it, and a bulk action sends the rule to the server as one updateWhere that answers with the count it changed. The selection bar shows the server's number, not the ticks on screen. (requires @svgrid/enterprise)
A live, editable Svelte 5 data grid example from the SvGrid gallery (Server-Side Row Model). See the SvGrid documentation for the full API.
About this example
Select-all across unloaded rows in a Svelte 5 data grid over server-side data. The header checkbox selects every row the current filter matches, loaded or not, and the selection becomes a rule rather than a list: all except these ids, or per group under grouping with groupSelects descendants. The panel shows the rule as getSelectionState reports it, Save and Restore round-trip it through setSelectionState, and a bulk action sends the rule to the server as one updateWhere call that answers with the number of rows it changed. The selection bar's count is the server's number, not the ticks on screen.
The header checkbox selects every row the filter matches, loaded or not, and the selection becomes a rule rather than a list: "all, except these ids", or per group under grouping. The panel shows that rule live, as getSelectionState() reports it; Save and Restore round-trip it; a bulk action sends the rule to the server as one updateWhere and the affected count comes back. The count chip on the selection bar is the server's number, not the number of ticks on screen.
The row model and the selection rules are Enterprise; the datasource contract the in-memory reference source implements is free.
Imports, features and API used
Imports: @svgrid/grid, @svgrid/enterprise, ../shared/mock-api
Table features registered: rowSortingFeature, columnFilteringFeature, rowSelectionFeature
Frequently asked questions
How can the grid select rows it has never loaded?
It does not keep a list. The selection is a rule: selectAll plus the ids toggled the other way. A row is selected when the rule says so, the count comes from the server's row count minus the exceptions, and a bulk edit sends the rule and the filter to updateWhere so the backend applies the change to exactly that set.
What changes under grouping?
The rule nests per group: ticking a plan selects its subscribers, loaded or not, and unticking one of them records an exception on that plan. getSelectionState returns the nested shape and updateWhere receives it with the group columns, so the server can walk it.
Does a filter change what select-all means?
Yes. The rule is evaluated against the filtered set: with a status filter open, select-all selects the matching rows only, the count reflects the filtered row count, and the bulk edit carries the filter model along with the rule.
Related documentation
Related articles
- Stable Row Identity in SvGrid (and why it matters) - How SvGrid tracks rows across mutations, why object reference stability is the key to correct selection and editing, and the exact patterns to use with live data feeds.
- A Fill Handle (Drag to Fill) in SvGrid - Build a working spreadsheet-style fill handle on top of SvGrid's cell selection and editing - pointer tracking, range highlighting, series fill, and undo/redo integration all covered.
- Copy a Cell Range to the Clipboard in SvGrid - How SvGrid copies selected cell ranges as tab-separated values that paste cleanly into Excel and Google Sheets, plus headers, programmatic copy, and format control.
Source code (471-server-selection.svelte)
<!-- Documented in: docs/help/server/server-selection.md -->
<script lang="ts">
/**
* 471. Server selection: select all, minus these
* ----------------------------------------------
* The header checkbox selects every row the filter matches, loaded or not,
* and the selection becomes a rule rather than a list: "all, except these
* ids", or per group under grouping. The panel shows that rule live, as
* `getSelectionState()` reports it; Save and Restore round-trip it; a bulk
* action sends the rule to the server as one `updateWhere` and the
* affected count comes back. The count chip on the selection bar is the
* server's number, not the number of ticks on screen.
*
* The row model and the selection rules are Enterprise; the datasource
* contract the in-memory reference source implements is free.
*/
import { SvGrid, renderComponent, tableFeatures, rowSortingFeature, columnFilteringFeature, rowSelectionFeature, type GridColumns } from '@svgrid/grid'
import {
installEnterprise,
createInMemoryDataSource,
createServerRowModel,
serverGroupText,
SvGroupCell,
type EntitySchema,
type ServerRowModel,
type ServerRowModelState,
type ServerRowModelGridRow,
type ServerSelectionState,
type ServerGroupSelectionNode,
} from '@svgrid/enterprise'
import { createPrng } from '../shared/mock-api'
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowSelectionFeature })
// ---- The server ----------------------------------------------------------
type Subscriber = { id: number; email: string; plan: string; status: string; country: string; mrr: number; signedUp: string }
const PLANS = ['Free', 'Pro', 'Team']
const STATUS = ['active', 'active', 'active', 'paused', 'churned']
const COUNTRIES = ['US', 'DE', 'UK', 'FR', 'JP', 'BR', 'IN', 'AU', 'CA', 'NL']
const NAMES = ['ada', 'grace', 'linus', 'margaret', 'ken', 'barbara', 'dennis', 'donald', 'brian', 'tim']
const ROWS = 100_000
const rng = createPrng(0x5e1ec7)
const DB: Subscriber[] = Array.from({ length: ROWS }, (_, i) => {
const plan = rng.pick(PLANS)
return {
id: i + 1,
email: `${rng.pick(NAMES)}.${(i + 1).toString(36)}@example.com`,
plan,
status: rng.pick(STATUS),
country: rng.pick(COUNTRIES),
mrr: plan === 'Free' ? 0 : plan === 'Pro' ? rng.int(9, 29) : rng.int(49, 199),
signedUp: new Date(Date.now() - rng.int(1, 900) * 86_400_000).toISOString().slice(0, 10),
}
})
const schema: EntitySchema<Subscriber> = {
name: 'subscribers',
fields: [
{ field: 'id', type: 'number', primaryKey: true, readonly: true },
{ field: 'email', type: 'text' },
{ field: 'plan', type: 'text' },
{ field: 'status', type: 'text' },
{ field: 'country', type: 'text' },
{ field: 'mrr', type: 'number' },
{ field: 'signedUp', type: 'date' },
],
}
// The reference backend: sort, filter, group, count and `updateWhere` (the
// bulk edit by rule) over the array, behind a little latency.
const memory = createInMemoryDataSource(DB, schema)
let requests = $state(0)
let lastBulk = $state('')
const source: typeof memory = {
...memory,
async getRows(req) {
requests += 1
await new Promise((r) => setTimeout(r, 120))
return memory.getRows(req)
},
async updateWhere(filterModel, patch, rule) {
await new Promise((r) => setTimeout(r, 200))
const n = await memory.updateWhere(filterModel, patch, rule)
lastBulk = `updateWhere: ${n.toLocaleString()} rows set to ${JSON.stringify(patch)}`
return n
},
}
// ---- The model ---------------------------------------------------------
type Mode = 'flat' | 'grouped'
type Row = ServerRowModelGridRow<Subscriber>
let mode = $state<Mode>('flat')
let view = $state<ServerRowModelState<Subscriber>>()
let ctl = $state.raw<ServerRowModel<Subscriber>>(makeModel('flat'))
function makeModel(m: Mode) {
const model = createServerRowModel<Subscriber>(source, {
groupBy: m === 'grouped' ? ['plan'] : [],
// The count on the grand total is what makes "100,000 selected" a
// number the model can stand behind under grouping.
aggregations: [
{ col: 'mrr', fn: 'sum' },
{ col: 'id', fn: 'count' },
],
grandTotalRow: 'pinnedBottom',
getRowId: (r) => String(r.id),
childCount: (r) => (r as { childCount?: number }).childCount,
isGroupOpenByDefault: () => true,
// Flat: one rule, "all except these". Grouped: a ticked plan ticks
// its subscribers, loaded or not, and a leaf unticked under it is an
// exception on that plan.
selection: { groupSelects: m === 'grouped' ? 'descendants' : 'self' },
blockSize: 100,
skeletonRows: 5,
filterValues: async (columnId) => [...new Set(DB.map((r) => String(r[columnId as keyof Subscriber])))].sort(),
onChange: (s) => (view = s),
})
model.refresh()
return model
}
function setMode(next: Mode) {
if (next === mode) return
ctl.dispose()
mode = next
saved = null
ctl = makeModel(next)
}
$effect(() => () => ctl.dispose())
// ---- The rule ------------------------------------------------------------
type Rule = ServerSelectionState | ServerGroupSelectionNode
const rule = $derived.by((): Rule | null => {
void view
return ctl.getSelectionState()
})
const selectedCount = $derived.by(() => {
void view
return ctl.selection?.selectedCount?.() ?? 0
})
// The rule as JSON, with long exception lists folded so it stays readable.
const ruleText = $derived.by(() => {
if (!rule) return 'null'
const fold = (v: unknown): unknown => {
if (Array.isArray(v)) return v.length > 12 ? [...v.slice(0, 12), `... ${v.length - 12} more`] : v
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fold(x)]))
return v
}
return JSON.stringify(fold(rule), null, 2)
})
let saved = $state<Rule | null>(null)
function save() {
saved = ctl.getSelectionState()
}
function restore() {
if (saved) ctl.setSelectionState(saved)
}
let busy = $state(false)
async function bulk(patch: Partial<Subscriber>) {
if (!selectedCount || busy) return
busy = true
try {
await ctl.bulkUpdate(patch)
} finally {
busy = false
}
}
// A rule worth saving is one that selects something.
const hasRule = $derived(selectedCount > 0)
// The leaf total: the count aggregate on the grand total, which is what
// the selection model divides by too.
const total = $derived(Number(view?.grandTotal?.id ?? view?.rowCount ?? ROWS))
// ---- Columns --------------------------------------------------------------
const usd = { type: 'number' as const, options: { style: 'currency' as const, currency: 'USD', maximumFractionDigits: 0 } }
const groupColumn: GridColumns<Row>[number] = {
id: 'group',
header: 'Plan',
width: 200,
sortable: false,
filterable: false,
fieldFn: (row) => serverGroupText(row, 'email'),
cell: (ctx) =>
renderComponent(SvGroupCell, {
row: ctx.row.original,
onToggle: () => ctl.group.onToggle(ctx.row.original),
leafField: 'email',
}),
}
const idColumn: GridColumns<Row>[number] = {
field: 'id',
header: 'Id',
width: 70,
align: 'right',
editable: false,
formatter: ({ value, row }) => (row?.original.__group?.kind === 'leaf' ? String(value) : ''),
}
const rest: GridColumns<Row> = [
{ field: 'status', header: 'Status', width: 90, editorType: 'select', editorOptions: ['active', 'paused', 'churned'] },
{ field: 'country', header: 'Country', width: 80 },
{ field: 'mrr', header: 'MRR', width: 90, align: 'right', format: usd },
{ field: 'signedUp', header: 'Signed up', width: 110 },
]
const flatColumns: GridColumns<Row> = [
idColumn,
// The pinned grand total is formatted from the accessor with no row.
{ field: 'email', header: 'Email', width: 200, formatter: ({ value, row }) => (row ? String(value ?? '') : 'Grand total') },
{ field: 'plan', header: 'Plan', width: 80 },
...rest,
]
const groupedColumns: GridColumns<Row> = [groupColumn, idColumn, ...rest]
const columns = $derived(mode === 'grouped' ? groupedColumns : flatColumns)
</script>
<section class="wrap demo-kit">
<header class="chrome">
<div class="seg mode-seg" role="group" aria-label="Layout">
<button type="button" class:is-on={mode === 'flat'} aria-pressed={mode === 'flat'} onclick={() => setMode('flat')}>Flat</button>
<button type="button" class:is-on={mode === 'grouped'} aria-pressed={mode === 'grouped'} onclick={() => setMode('grouped')}>Grouped by plan</button>
</div>
<div class="actions">
<button type="button" class="btn" disabled={!selectedCount || busy} onclick={() => bulk({ status: 'paused' })} title="One updateWhere with the rule; the server answers with the count it changed">{busy ? 'Applying...' : 'Pause selected'}</button>
<button type="button" class="btn" disabled={!selectedCount || busy} onclick={() => bulk({ status: 'active' })} title="The same updateWhere, the other way">Activate selected</button>
<button type="button" class="btn" disabled={!hasRule} onclick={save} title="Keep the rule as data (getSelectionState)">Save rule</button>
<button type="button" class="btn" disabled={!saved} onclick={restore} title="Put the saved rule back (setSelectionState)">Restore rule</button>
</div>
<span class="note">
Tick the header checkbox: 100,000 rows are selected and the grid has loaded a hundred. Untick a few
and the rule reads "all except these"; open a column filter first and the rule applies to the
filtered set. Under grouping a plan ticks its subscribers, loaded or not.
</span>
</header>
<div class="body">
<div class="gridpane">
{#key mode}
<SvGrid
responsive={true}
columnResize
fitColumns
rowModel={ctl}
stickyGroupRows
{columns}
{features}
sortable
filterable
filterMode="menu"
showRowSelection
selectionBar={['selectAll', 'editFields']}
rowHeight={32}
containerHeight="100%"
onApiReady={(next) => installEnterprise(next)}
/>
{/key}
</div>
<aside class="rule" aria-label="Selection rule">
<div class="rule-head">getSelectionState() <span class="muted">live</span></div>
<pre class="rule-body">{ruleText}</pre>
{#if saved}<div class="rule-saved">Saved: {'selectAll' in saved ? `${saved.selectAll ? 'all' : 'none'}, ${saved.toggled.length} exception${saved.toggled.length === 1 ? '' : 's'}` : 'a per-plan rule'}</div>{/if}
</aside>
</div>
<footer class="foot">
<span class="stat" data-stat="selected"><span class="stat-label">Selected</span><strong>{selectedCount.toLocaleString()}</strong> of {total.toLocaleString()}</span>
<span class="stat"><span class="stat-label">Loaded</span><strong>{(view?.gridRows.filter((r) => r.__group?.kind === 'leaf').length ?? 0).toLocaleString()}</strong> rows</span>
<span class="stat"><span class="stat-label">Requests</span><strong>{requests}</strong></span>
{#if view?.error}<span class="stat err">{String((view.error as Error).message ?? view.error)}</span>{/if}
{#if lastBulk}<span class="stat last">{lastBulk}</span>{/if}
</footer>
</section>
<style>
/* Only what is particular to this demo; the chrome is the shared demo-kit. */
.rule {
width: 280px;
flex: none;
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid var(--sg-border, #e2e8f0);
border-radius: 10px;
background: var(--sg-bg, #fff);
font-size: 12px;
}
.rule-head {
padding: 8px 10px;
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--sg-fg, #0f172a);
background: var(--sg-header-bg, #f8fafc);
border-bottom: 1px solid var(--sg-border, #e2e8f0);
border-radius: 10px 10px 0 0;
}
.rule-body {
flex: 1;
min-height: 0;
overflow: auto;
margin: 0;
padding: 10px;
font-size: 11.5px;
line-height: 1.45;
color: var(--sg-fg, #0f172a);
}
.rule-saved { padding: 8px 10px; border-top: 1px solid var(--sg-border, #e2e8f0); color: var(--sg-muted, #64748b); }
@media (max-width: 900px) {
.rule { width: auto; max-height: 180px; }
}
</style>More Server-Side Row Model examples
- Server-side pivot - The pivot designer in server mode over a million rows: Rows become groupBy, Columns pivotBy, Values aggregations, and every applied layout is one request. The backend answers with one field per pivot key and aggregation and lists them in pivotResultFields; the model builds the column groups from that list. Apply / Cancel hold a slice-and-dice session to one request, a Total column group carries the row totals, and a grand total row is pinned at the bottom.
- Server grouping (row model) - Server-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.
- Server tree data (row model) - A file tree the grid never holds whole: expanding a folder is one getRows with the folder path as groupKeys, answered with that folder's entries one block at a time. createServerRowModel in treeData mode owns the lazy expand, a block cache per folder, open-by-default, expand and collapse all, a per-folder refresh that re-reads one folder in place, and transactions that add or delete a file without a refetch. The server generates each folder from a seeded PRNG on first request, five levels deep.
- Server transactions (live feed) - A socket-style feed of changes the server already made, applied without a refetch: a price tick patches the loaded row in place with a flash (updateRowData), a new order lands at the top of its warehouse and a shipped one leaves (applyTransactionAsync, batched every 500 ms, addressed by route). Every result carries a status the log shows: applied, cancelled under the veto hook, storeNotFound for a warehouse whose level is not cached. Refresh totals recomputes the sums a transaction leaves alone.
- Server row model to SQL - What the backend runs for each request: planQuery turns the ServerRequest into a QueryPlan against an entity schema, planToSql renders it for Postgres, MySQL or SQLite, and the panel shows the statements createSqlDataSource would hand your executor for the request that just went out - grouped or flat rows, the count, the grand total, and the two statements a pivot needs. The rows come from the in-memory reference source over the same plan.