gp-grid-logo
Guides

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>DefaultMeaning
dimensionsrequiredOrdered { field, id? }. id defaults to field, names the level in paths and ids, and must be unique.
measuresnone{ 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.
getRowIdrequired(row) => RowId: the id of a leaf record.
defaultExpandedDepth0Groups of a depth below it start expanded; 0 collapses every group.
grandTotalnone"top" or "bottom" adds one total row, filled by the root response's totals.
initialStatenoneA RowGroupingState, from getState() or built from ids (see Keys, paths and ids).
cachepageSize 100, "balanced"RowCacheOptions; see Budgets.
maxConcurrentRequests4Requests in flight at once.
writablefalseLets 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>:

MemberMeaning
loadMode"all": the grid loads it with one query, like any source that returns a hierarchy.
writableFrom 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.

ServerGroupChildrenRequestMeaning
revisionnull on the root request of a new view; afterwards the revision the root response reported.
grouping{ dimensions, measures } from the configuration.
pathThe 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.
sortThe grid's SortModel[], when set.
filterThe grid's FilterModel, when set.
signalAborted when the response can no longer be used. A service may ignore it.
ServerGroupChildrenResponseMeaning
revisionThe data version the answer reflects.
totalCountThe exact number of the parent's children.
childrenServerGroupNodes: children[i] is child range.startRow + i of the parent.
totalsThe parent's own aggregates, keyed by measure field. The root's fill the total row.
ServerGroupNodeMeaning
keyA string, number, boolean, null or Date. It is the group row's value.
childCountThe exact number of immediate children: groups of the next dimension, or leaves under a group of the last one.
leafCountThe exact number of records below the group, the label's {count}.
aggregatesKeyed 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,
];
TagKeyExample
znull: a null, undefined or missing value["city", "z", null]
sa string["city", "s", "Rome"]
na number; -0 is 0["year", "n", 2026]
ba boolean["active", "b", true]
da 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

  1. query sends 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.
  2. 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 revision differs from the view's (Stale revision 3, expected 2);
  • totalCount is not a non-negative safe integer (Invalid totalCount: -1);
  • totalCount differs from the parent's childCount (totalCount 12 differs from the declared 11);
  • children or rows is not exactly the range clipped to totalCount (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 as error: rows.getViewRow(i) is { kind: "group", expanded: false, error: "…", … }. Its siblings keep their pages and expansion.
  • That row gets .gp-grid-row--error and data-row-error. Its label cell's title is labels.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 error and 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 fieldDefaultMeaning
pageSize100Children or leaves per request.
prefetchPagesfrom evictionPages requested beside each page in view, within the same group.
maxPagesfrom evictionLoaded 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 maxConcurrentRequests requests 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 pageSize groups sends a second root request right after the first load. Set prefetchPages: 0 when a test or a log needs exact counts.

Limits

  • Counts are exact: totalCount, childCount and leafCount. 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's locate. An unloaded leaf and a group id answer undefined.
  • A group row shows no loading state beyond its placeholders, and a failure shows only as error and the data error.

For the complete API contracts and advanced examples, see the upstream server grouping guide.

On this page