Server grouping
Load grouped rows from your backend using bounded paging, lazy expansion, explicit service contracts, and recoverable errors in React, Vue, and Angular grids.
A server grouping source shows a grouped view of a dataset that stays on a server. The grid asks an application service for the children of one group at a time, one page at a time, and never downloads the leaves of a collapsed group. The server owns membership, order, filtering and aggregates; the grid computes none of them.
Use it when the rows do not fit in the browser, or when the grouping belongs to
the server. When every row is already resident, use the local engine
(createRowGrouping). When the application already holds
the grouped rows, return a
HierarchicalRowAccess
from its own source.
createServerGroupingSource(service, config) returns a DataSource. Every
query builds a view: a HierarchicalRowAccess over the loaded group nodes and
counted gaps. The grid binds it like any supplied hierarchy, so group rows, the
label column, the expansion commands, frozen rows, row heights and the DOM are
those of row grouping. An application that groups on the
server bundles none of the local engine.
@gp-grid/core owns geometry, focus and the scroll anchor; the view owns the row
index, the placeholders and the page budget; the service owns the data. A
wrapper renders what the core publishes.
Configuration
import { createServerGroupingSource } from "@gp-grid/core"; // or any wrapper package
const source = createServerGroupingSource(service, {
dimensions: [{ field: "country" }, { field: "city" }],
measures: [
{ field: "amount", aggregate: "sum" },
{ field: "score", aggregate: "avg" },
],
getRowId: (row) => row.id,
grandTotal: "top",
});ServerGroupingConfig<TData> | Default | Meaning |
|---|---|---|
dimensions | required | Ordered { field, id? }. id defaults to field, names the level in paths and ids, and must be unique. |
measures | none | { field, source?, aggregate }, sent to the service as is. aggregate is a name the server understands; the local engine's are "sum", "count", "avg", "min" and "max". Group and total rows show the result under field. |
getRowId | required | (row) => RowId: the id of a leaf record. |
defaultExpandedDepth | 0 | Groups of a depth below it start expanded; 0 collapses every group. |
grandTotal | none | "top" or "bottom" adds one total row, filled by the root response's totals. |
initialState | none | A RowGroupingState, from getState() or built from ids (see Keys, paths and ids). |
cache | pageSize 100, "balanced" | RowCacheOptions; see Budgets. |
maxConcurrentRequests | 4 | Requests in flight at once. |
writable | false | Lets the grid write leaf cells; see Refresh and writes. |
There is no toKey and no custom aggregator: the server buckets and folds.
Creation throws RangeError("Invalid serverGrouping.<field>: <value>") for no
dimension, a repeated dimension id, a repeated measure field, a negative
defaultExpandedDepth, a missing getRowId or a maxConcurrentRequests that is
not positive.
ServerGroupingSource<TData> is a DataSource<TData>:
| Member | Meaning |
|---|---|
loadMode | "all": the grid loads it with one query, like any source that returns a hierarchy. |
writable | From the configuration. |
query(request) | Releases the current view, sends the root request and resolves with the new view as access and rows: []. The grid calls it. |
getState() | The expansion by id, kept across queries. |
destroy() | Releases the current view. |
One source serves one grid: every query releases the view of the query before.
Keep it referentially stable (a module constant, useMemo, a shallowRef, a
class field). A new source is a new data source and starts from its
initialState.
Every wrapper re-exports createServerGroupingSource, encodeGroupId,
decodeGroupId, HierarchyChange and the service types. The source goes in as
dataSource; there are no new props.
// React
<Grid columns={columns} dataSource={source} rowHeight={32} groupLabelColumn="country" /><!-- Vue -->
<GpGrid :columns="columns" :data-source="source" :row-height="32" group-label-column="country" /><!-- Angular -->
<gp-grid [columns]="columns" [dataSource]="source" [rowHeight]="32" groupLabelColumn="country" />// Core
const core = new GridCore({ columns, dataSource: source, rowHeight: 32 });Do not also set rowGrouping: over this source it is rejected with
hierarchical-source, as over any supplied hierarchy, and the server's groups
stay. groupLabelColumn, groupLabelRenderer, onRowGroupToggled,
core.rowGroups and the labels work as in row grouping.
The service
interface ServerGroupingService<TData> {
fetchChildren(request: ServerGroupChildrenRequest): Promise<ServerGroupChildrenResponse>;
fetchLeaves(request: ServerLeavesRequest): Promise<ServerLeavesResponse<TData>>;
}Both methods are required. The service is in-process TypeScript: the wire format, the transport, retries and timeouts belong to the application. The requests carry no callbacks: paths are typed steps and aggregates are names. What a transport must encode is in Transport.
Group children
fetchChildren asks for the immediate children of the group at path, or of
the root when path is []. The children are groups of dimension
path.length.
ServerGroupChildrenRequest | Meaning |
|---|---|
revision | null on the root request of a new view; afterwards the revision the root response reported. |
grouping | { dimensions, measures } from the configuration. |
path | The parent's path, [] for the root. |
range | { startRow, endRow }: the slice of the parent's children to return, counted from 0 in the requested order, endRow excluded, as the flat DataSourceRequest.range. |
sort | The grid's SortModel[], when set. |
filter | The grid's FilterModel, when set. |
signal | Aborted when the response can no longer be used. A service may ignore it. |
ServerGroupChildrenResponse | Meaning |
|---|---|
revision | The data version the answer reflects. |
totalCount | The exact number of the parent's children. |
children | ServerGroupNodes: children[i] is child range.startRow + i of the parent. |
totals | The parent's own aggregates, keyed by measure field. The root's fill the total row. |
ServerGroupNode | Meaning |
|---|---|
key | A string, number, boolean, null or Date. It is the group row's value. |
childCount | The exact number of immediate children: groups of the next dimension, or leaves under a group of the last one. |
leafCount | The exact number of records below the group, the label's {count}. |
aggregates | Keyed by measure field. A missing or null aggregate is "none", never a loading state: the cell renders empty. |
Leaves
fetchLeaves asks for the records of a terminal group, a group of the last
dimension: path has one step per dimension. ServerLeavesRequest has the same
fields as a children request, with a numeric revision. The response is
ServerLeavesResponse<TData> = { revision, totalCount, rows }: rows[i] is
record range.startRow + i of the group's leaves, and a record's id is getRowId(row). The grid
never asks for leaves at the root or under a collapsed group.
Path steps
type GroupPathStep = readonly [
dimensionId: string,
tag: "z" | "s" | "n" | "b" | "d",
key: string | number | boolean | null,
];| Tag | Key | Example |
|---|---|---|
z | null: a null, undefined or missing value | ["city", "z", null] |
s | a string | ["city", "s", "Rome"] |
n | a number; -0 is 0 | ["year", "n", 2026] |
b | a boolean | ["active", "b", true] |
d | a date, as its timestamp in ms | ["day", "d", 1767225600000] |
dimensionId is the dimension's id ?? field. The grid builds a step from the
node key the service returned, so a Date key comes back as ["d", timestamp]
and the service matches it with getTime(). A non-finite number (NaN,
±Infinity) stays a number in a step.
The order of requests
querysends the root request:revision: null,path: [],range: [0, pageSize)and the query's sort and filter. Its response sets the view's revision and the root's count.- As rows come into view, the view requests their pages: children pages under
a group of an inner dimension, leaf pages under a terminal group. Every
request carries the view's revision, the same sort and filter, and its own
signal.
Protocol rules
A response is checked before it touches the view. It is a protocol error for its branch when:
- its
revisiondiffers from the view's (Stale revision 3, expected 2); totalCountis not a non-negative safe integer (Invalid totalCount: -1);totalCountdiffers from the parent'schildCount(totalCount 12 differs from the declared 11);childrenorrowsis not exactly the range clipped tototalCount(Page too long: 101 rows for 100,Page too short: 9 children for 10);- a node key is an object that is not a
Date(Invalid group key at child 4).
The checks run in that order and the first failure is the message. The root
response of a new view sets the revision and may report any count, but its
length and keys are checked. childCount and leafCount are not checked on
arrival: a wrong childCount sizes the expanded group's placeholders until its
own children response fails the count check.
A protocol error fails its branch like a rejected request (see
Errors and the retry). A response that arrives after its
group collapsed, after a new query or after destroy() is dropped unchecked.
Errors and the retry
A page request that rejects, or whose response is a protocol error, fails its branch.
- Under a group, the group collapses, the collapse is recorded in
getState()and its row carries the message aserror:rows.getViewRow(i)is{ kind: "group", expanded: false, error: "…", … }. Its siblings keep their pages and expansion. - That row gets
.gp-grid-row--erroranddata-row-error. Its label cell'stitleislabels.rowGroups.loadFailed, default"Loading failed. Expand again to retry.", and the shipped CSS colors the label with--gp-grid-error-text(:where([data-row-error] .gp-grid-cell--group-label)). - Expanding the group again clears
errorand requests its pages again. That is the retry: there is no retry command, backoff or error callback.
const labels = { rowGroups: { loadFailed: "Could not load. Expand to try again." } };The root has no group row. When the root request of a query fails, query
rejects and the grid shows its data error (DATA_ERROR; the wrappers print
labels.errorPrefix). When a later root page fails, the grid shows the same
error until the next query, and that page stays placeholders.
Budgets
The view keeps a bounded number of loaded pages, configured by the
RowCacheOptions a paged flat source uses:
cache field | Default | Meaning |
|---|---|---|
pageSize | 100 | Children or leaves per request. |
prefetchPages | from eviction | Pages requested beside each page in view, within the same group. |
maxPages | from eviction | Loaded pages kept across the view. |
eviction | "balanced" | "aggressive" (prefetch 0, 1 page), "balanced" (1, 5) or "conservative" (2, 10). |
- The pages in view are required: every page with a row in the frozen prefix or the overscan window. Their prefetch neighbours are wanted too. The rows between the frozen prefix and a deep window are never requested.
- At most
maxConcurrentRequestsrequests are in flight. Required pages start before prefetch pages, in view order. A queued page that stops being wanted, after a fast scroll, is never sent. A page in flight is never sent twice and a scroll does not abort it. - Loaded pages above
max(maxPages, required pages)are evicted, least recently required first. A required page is never evicted, and neither is a children page holding an expanded group, whose nodes carry the counts of the expanded rows. Eviction turns rows back into placeholders and moves no row. - Collapsing a group is the only unload of its branch. There is no budget on expanded branches.
- Prefetch changes request counts. On the balanced preset, expanding a group of
more than one page sends the page in view and the next one, and a root of more
than
pageSizegroups sends a second root request right after the first load. SetprefetchPages: 0when a test or a log needs exact counts.
Limits
- Counts are exact:
totalCount,childCountandleafCount. Estimated or unknown counts are not supported. - Paging is by position range over a parent's children. Cursor-only paging is not supported.
- No pivot (a server that computes one supplies it precomputed), drill-through or aggregate windows, and no client-side aggregation: the grid shows the aggregates the server sends.
- Nothing is cached across queries: a sort, a filter, a refresh or a new source drops every page, and only the expansion survives.
- After a refresh the active cell and the anchor follow their row by id when the new view has it at once; otherwise they keep their index on the placeholder there, which may load as another row (see Refresh and writes).
- Expand all opens loaded groups; the rest open as their pages arrive.
- A write changes the cached leaf only, until a refresh.
rows.getRecordById(id)finds a loaded leaf through the view'slocate. An unloaded leaf and a group id answerundefined.- A group row shows no loading state beyond its placeholders, and a failure
shows only as
errorand the data error.
For the complete API contracts and advanced examples, see the upstream server grouping guide.