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. (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
Server-side grouping in the Svelte 5 data grid through the same getRows contract. The request carries groupBy and groupKeys, createServerRowModel owns the group tree with a block cache per level, lazy expand, per-group sums with a subtotal footer and race safety, and the grid mounts it through the one rowModel prop; SvRowGroupPanel lets users regroup on the fly. The leaves under a country arrive three ways, switchable in the demo: by scroll, behind a Load N more row, or paged across the whole tree. The server here holds 63,000 rows behind 200 ms latency and the grid only ever holds the groups you expand.
Server-side grouping through ONE getRows contract. The request carries groupBy + groupKeys; createServerRowModel owns the group tree (lazy expand per level, a block cache per level, aggregation, race-safety) and the grid mounts it through the one rowModel prop. Here a 63,000-row in-memory "server" behind 200ms latency - the grid only ever holds what you expand.
Three ways to walk the leaves under a country: scroll them in block by block, click "Load N more" per block, or page the whole tree.
The row model and its chrome are Enterprise. The datasource contract it runs on (ServerDataSource, ServerRequest) is free in @svgrid/grid.
Imports, features and API used
Imports: @svgrid/grid, @svgrid/enterprise
Columns: product (Product), qty (Qty), amount (Amount)
Frequently asked questions
How does the server know which group to return?
Each getRows request carries groupBy, the ordered list of group columns, and groupKeys, the path of expanded keys. An empty path asks for the top-level groups with their aggregates; a path of one key asks for that group's children, one block at a time (startRow to endRow).
Is a group refetched every time it is expanded?
No. createServerRowModel keeps a block cache per level, so collapsing and re-expanding renders from memory. Changing groupBy clears the tree; refresh({ route }) reloads one level in place.
How do users change the grouping?
SvRowGroupPanel shows the groupable columns as chips; its onChange calls the model's setGroupBy and the model requests the new top level. With applyMode 'deferred' the chips collect edits behind Apply / Cancel so one session of changes is one request.
Can the tree be paged instead of scrolled?
Yes. The pagination option pages the top level, or the whole flattened tree with paginateChildRows, and the grid's footer pager drives it. A level can also load one block per click behind a Load N more row with levelParams loadMore.
Related documentation
Related articles
- Inside SvGrid: The Row Model and Sorting - How sorting shaped SvGrid's row-model pipeline - the decisions made early that every later feature inherited.
- Inside SvGrid: Grouping, Trees, and Master-Detail - Three different ways to show hierarchy in a data grid, unified under one expansion model in SvGrid - the design decision and how each feature actually works.
- Using SvGrid with TanStack Query in Svelte - Wire TanStack Query's caching and background refetch into SvGrid for a server-driven grid that pages instantly and never shows a blank screen.
Source code (496-server-grouping-model.svelte)
<!-- Documented in: docs/help/server/server-grouping.md -->
<script lang="ts">
/**
* 496. Server grouping (row model)
* --------------------------------
* Server-side grouping through ONE getRows contract. The
* request carries groupBy + groupKeys; createServerRowModel owns the group
* tree (lazy expand per level, a block cache per level, aggregation,
* race-safety) and the grid mounts it through the one `rowModel` prop.
* Here a 63,000-row in-memory "server" behind 200ms latency - the grid only
* ever holds what you expand.
*
* Three ways to walk the leaves under a country: scroll them in block by
* block, click "Load N more" per block, or page the whole tree.
*
* The row model and its chrome are Enterprise. The datasource contract it
* runs on (ServerDataSource, ServerRequest) is free in @svgrid/grid.
*/
import { SvGrid, renderComponent, tableFeatures, type GridColumns, type ServerDataSource } from '@svgrid/grid'
import {
setLicenseKey,
createServerRowModel,
serverGroupText,
SvGroupCell,
SvRowGroupPanel,
type ServerRowModelState,
type ServerRowModelGridRow,
} from '@svgrid/enterprise'
setLicenseKey('SVENTERPRISE-DEV-LOCAL')
const features = tableFeatures({})
// Group rows carry an `n` (row count) the server adds beside the sums.
type Sale = { region: string; country: string; rep: string; product: string; qty: number; amount: number; n?: number }
const REGIONS: Record<string, string[]> = {
Americas: ['US', 'BR', 'CA'],
EMEA: ['DE', 'UK', 'FR'],
APAC: ['JP', 'AU', 'IN'],
}
const PRODUCTS = ['Desk', 'Chair', 'Lamp', 'Monitor', 'Cabinet', 'Whiteboard']
// The "database": 63,000 rows that never touch the grid wholesale.
const DB: Sale[] = (() => {
const out: Sale[] = []
let i = 0
for (const [region, countries] of Object.entries(REGIONS))
for (const country of countries)
for (let k = 0; k < 7000; k++, i++) {
// Hashed rather than cycled, so the per-country sums differ.
const h = Math.imul(i, 2654435761) >>> 0
const qty = 1 + (h % 12)
out.push({ region, country, rep: `Rep ${i % 50}`, product: PRODUCTS[i % PRODUCTS.length]!, qty, amount: qty * (120 + ((h >>> 8) % 880)) })
}
return out
})()
// The server: GROUP BY the requested level within groupKeys, or return leaves.
let requests = $state(0)
const source: ServerDataSource<Sale> = {
async getRows(req) {
requests += 1
await new Promise((r) => setTimeout(r, 200)) // simulated latency
// `groupBy` / `groupKeys` are optional on ServerRequest (a flat source may
// omit them); this grouped source is always called with both.
const subset = DB.filter((r) =>
req.groupKeys!.every((k, i) => String((r as Record<string, unknown>)[req.groupBy![i]!]) === k),
)
const level = req.groupKeys!.length
if (level < req.groupBy!.length) {
const field = req.groupBy![level]!
const map = new Map<string, Record<string, unknown>>()
for (const r of subset) {
const key = String((r as Record<string, unknown>)[field])
const g = map.get(key) ?? { [field]: (r as Record<string, unknown>)[field], amount: 0, qty: 0, n: 0 }
g.amount = (g.amount as number) + r.amount
g.qty = (g.qty as number) + r.qty
g.n = (g.n as number) + 1
map.set(key, g)
}
const rows = [...map.values()] as unknown as Sale[]
return { rows: rows.slice(req.startRow, req.endRow), rowCount: rows.length }
}
// Honor the requested block: the model asks for one block at a time.
return { rows: subset.slice(req.startRow, req.endRow), rowCount: subset.length }
},
}
// How the leaves under a country arrive. The block cache is the same in all
// three; only what drives it changes.
type Mode = 'scroll' | 'more' | 'paged'
const MODES: Array<{ id: Mode; label: string; hint: string }> = [
{ id: 'scroll', label: 'Scroll', hint: 'blocks load as you scroll, per level' },
{ id: 'more', label: 'Load more', hint: 'a "Load 20 more" row per block under each country' },
{ id: 'paged', label: 'Paged', hint: 'the whole tree, 25 rows a page, groups included' },
]
let mode = $state<Mode>('scroll')
let view = $state<ServerRowModelState<Sale>>()
function makeModel(m: Mode) {
const model = createServerRowModel<Sale>(source, {
groupBy: ['region', 'country'],
aggregations: [
{ col: 'amount', fn: 'sum' },
{ col: 'qty', fn: 'sum' },
],
blockSize: 20, // small, so the blocks are visible in every mode
groupFooters: true, // subtotal row after each expanded group
childCount: (row) => row.n, // the server's per-group row count, shown beside the key
// Americas and its first country open on load, so the level cascade
// (region request, country request, first leaf block) shows at once.
isGroupOpenByDefault: (route) => route[0] === 'Americas' && (route.length === 1 || (route.length === 2 && route[1] === 'US')),
// Under a country (level 2) the leaves come one block per click.
levelParams: m === 'more' ? (level) => (level === 2 ? { loadMore: true } : {}) : undefined,
pagination: m === 'paged' ? { pageSize: 25, pageSizes: [10, 25, 50], paginateChildRows: true } : undefined,
onChange: (s) => (view = s),
})
model.refresh()
return model
}
let ctl = $state.raw(makeModel('scroll'))
$effect(() => () => ctl.dispose())
function setMode(next: Mode) {
if (next === mode) return
ctl.dispose()
mode = next
ctl = makeModel(next)
}
const modeHint = $derived(MODES.find((m) => m.id === mode)!.hint)
// Columns the row-group panel lets you group / regroup by (drives ctl.setGroupBy).
const groupCols = [
{ id: 'region', label: 'Region' },
{ id: 'country', label: 'Country' },
{ id: 'rep', label: 'Rep' },
{ id: 'product', label: 'Product' },
]
// The model's grid rows carry each row's fields plus `__group`; the built-in
// SvGroupCell draws the expander, the indent and the "Load more" row.
type GridRow = ServerRowModelGridRow<Sale>
const usd = { type: 'number' as const, options: { style: 'currency' as const, currency: 'USD', maximumFractionDigits: 0 } }
const columns: GridColumns<GridRow> = [
{
id: 'group',
header: 'Group',
width: 300,
sortable: false,
filterable: false,
// The text behind the expander, for copy and export.
fieldFn: (row) => serverGroupText(row, 'rep'),
cell: (ctx) =>
renderComponent(SvGroupCell, {
row: ctx.row.original,
onToggle: () => ctl.group.onToggle(ctx.row.original),
leafField: 'rep',
}),
},
{ field: 'product', header: 'Product', width: 140 },
{ field: 'qty', header: 'Qty', width: 100, align: 'right', format: { type: 'number' } },
{ field: 'amount', header: 'Amount', width: 150, align: 'right', format: usd },
]
</script>
<section class="wrap demo-kit">
<header class="chrome">
<div class="seg mode-seg" role="group" aria-label="How leaves load">
{#each MODES as m (m.id)}
<button type="button" class:is-on={mode === m.id} aria-pressed={mode === m.id} title={m.hint} onclick={() => setMode(m.id)}>{m.label}</button>
{/each}
</div>
<span class="note">
Click a region to drill into its countries, then into the raw rows. Each expand is one
<code>getRows</code> with a longer <code>groupKeys</code>; 63,000 rows stay on the "server" and
the grid holds only what you expand. In this mode {modeHint}.
</span>
</header>
<SvRowGroupPanel columns={groupCols} groupBy={view?.groupBy ?? []} onChange={(g) => ctl.setGroupBy(g)} />
{#key mode}
<div class="gridpane">
<SvGrid
responsive={true}
columnResize
fitColumns
rowModel={ctl}
stickyGroupRows
{columns}
{features}
pageable={mode === 'paged'}
containerHeight="100%"
/>
</div>
{/key}
<footer class="foot">
<span class="stat"><span class="stat-label">Requests</span><strong>{requests}</strong></span>
<span class="stat"><span class="stat-label">On screen</span><strong>{(view?.gridRows.length ?? 0).toLocaleString()}</strong> rows of 63,000</span>
<span class="stat"><span class="stat-label">Open</span><strong>{view?.expandedGroups.length ?? 0}</strong> group{(view?.expandedGroups.length ?? 0) === 1 ? '' : 's'}</span>
{#if view?.error}<span class="stat err">{String((view.error as Error).message ?? view.error)}</span>{/if}
</footer>
</section>More Server-Side Row Model examples
- Server-Side Row Model: 1,000,000 rows - One grid, one rowModel prop, a million rows that stay on the server. Sort, filter, global search, grouping to any depth (Region > Country > Rep), infinite scroll or paging, inline edits applied back as transactions with the subtotal following, add and delete, select-all across rows the grid never loaded with a bulk edit by rule, failed blocks with Retry, a request log that shows every call to the columnar warehouse behind it, and a live map of the block cache per level. The row model ships in @svgrid/enterprise; the datasource contract is free.
- 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 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 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.