Row grouping and aggregation
Group resident records and calculate aggregates, with stable group identities, expansion controls, and read-only totals in React, Vue, and Angular grids.
Rows can be grouped by ordered dimensions, expanded and collapsed, with aggregates under their own columns. A group row is an ordinary row of cells: its expander and label sit in one label column and each aggregate sits under the column it belongs to. There is no full-width group row and no generated column.
The grid renders a hierarchy: view rows of kind record, group or
total. A hierarchy comes from the local engine (createRowGrouping, over rows
loaded in full) or from the data source itself (rows the application grouped,
or server grouping, which loads them page by page). All
reach the grid through one contract, HierarchicalRowAccess.
@gp-grid/core owns the view index, focus, the scroll anchor and the write
guards; the provider owns membership and aggregate values; a wrapper renders
what the core publishes. A grid without rowGrouping binds no hierarchy.
Configuration
import { createRowGrouping } from "@gp-grid/core"; // or any wrapper package
const columns = [
{ field: "country", cellDataType: "text", width: 180 },
{ field: "city", cellDataType: "text", width: 140 },
{ field: "name", cellDataType: "text", width: 140, editable: true },
{ field: "amount", cellDataType: "number", width: 120, editable: true },
{ field: "score", cellDataType: "number", width: 100, valueFormatter: (v) => Number(v).toFixed(1) },
];
const grouping = createRowGrouping({
dimensions: [{ field: "country" }, { field: "city" }],
measures: [
{ field: "amount", aggregate: "sum" },
{ field: "score", aggregate: "avg" },
],
grandTotal: "top",
});RowGroupingConfig | Default | Meaning |
|---|---|---|
dimensions | required | Ordered RowGroupDimensions: { field, id?, toKey? }. id defaults to field and names the bucketing. |
measures | none | RowGroupMeasures: { field, source?, aggregate }. Group and total rows expose the result under field, so the column with that field shows it; source, field by default, is the leaf field folded. |
defaultExpandedDepth | 0 | Groups of a depth below it start expanded; 0 collapses every group. |
grandTotal | none | "top" or "bottom" adds one total row; omitted adds none. |
initialState | none | A RowGroupingState from an earlier getState(). |
Column definitions carry no grouping or aggregate flag. createRowGrouping
throws RangeError("Invalid rowGrouping.<field>: <value>") for no dimension, a
repeated dimension id, a repeated measure field, an unknown built-in aggregate
name or a negative depth.
One RowGrouping serves one grid, and a new configuration is a new
createRowGrouping call. Keep the object referentially stable (a module
constant, useMemo, a shallowRef, a class field): a new object regroups.
getState() is its only public member; core regroups through it on every full
load and on setGrouping, and refuses an object that did not come from
createRowGrouping with a TypeError.
// React
<Grid columns={columns} rowData={rows} rowHeight={32} getRowId={(row) => row.id}
rowGrouping={grouping} groupLabelColumn="country"
onRowGroupToggled={(event) => console.log(event.rowId, event.expanded)}
onRowGroupingRejected={(rejection) => console.warn(rejection.reason)} /><!-- Vue -->
<GpGrid :columns="columns" :row-data="rows" :row-height="32" :get-row-id="(row) => row.id"
:row-grouping="grouping" group-label-column="country"
:on-row-group-toggled="(event) => console.log(event.rowId, event.expanded)"
:on-row-grouping-rejected="(rejection) => console.warn(rejection.reason)" /><!-- Angular -->
<gp-grid [columns]="columns" [rows]="rows" [rowHeight]="32" [getRowId]="getRowId"
[rowGrouping]="grouping" groupLabelColumn="country"
(onRowGroupToggled)="onToggled($event)"
(onRowGroupingRejected)="onRejected($event)" />// Core
const core = new GridCore({ columns, dataSource, rowHeight: 32, rowGrouping: grouping });
core.rowGroups.setGrouping(null); // back to the flat rows, no query
core.rowGroups.setGrouping(grouping); // regroup the resident rows, no queryA rejected setGrouping both returns { status: "rejected", rejection } and
reports the rejection through onRowGroupingRejected, like a rejected load.
rowGrouping is reactive in every wrapper: a changed value reaches the core
through rowGroups.setGrouping, never a new core. Angular's published input
typings do not accept null or undefined under strict templates, like
columnGroups: bind a RowGrouping, and cast to ungroup
(null as unknown as RowGrouping).
The engine groups the complete filtered and sorted result of a source loaded in full, object or columnar. It reads fields through the source's value access, so a columnar source is grouped without materializing records.
Aggregates
aggregate | Result | Null rules |
|---|---|---|
"sum" | Sum of the finite numbers | null when the group has none; non-numbers, NaN and ±Infinity are skipped |
"avg" | Sum divided by the count of finite numbers | null when the group has none |
"count" | Count of non-null values | 0 for none |
"min" / "max" | Extremum of the non-null values, by compareValues | null when every value is null |
RowGroupAggregator | result(state) | Receives every value, a missing one as null |
Every group folds its own leaves, so a parent avg is the average of its
leaves, not of its children's averages. The total row folds every filtered leaf.
import type { CellValue, RowGroupAggregator } from "@gp-grid/core";
const distinct: RowGroupAggregator<Set<CellValue>> = {
init: () => new Set(),
add: (seen, value) => (value === null ? seen : seen.add(value)),
result: (seen) => seen.size,
merge: (into, from) => {
for (const value of from) into.add(value);
return into;
},
};
createRowGrouping({
dimensions: [{ field: "country" }],
measures: [{ field: "city", aggregate: distinct }],
});init()must return a fresh state on every call: each group's state is retained for later refolds.merge(into, from)is optional. With it a parent combines its children's states instead of rereading their leaves. It must leavefromintact, because a child's state is merged again by every later refold of its parent. Every built-in has one.
A group or total row answers null for a column that is not a measure field.
Numeric aggregates are displayed unrounded (an avg can print many decimals):
give the measure's column a valueFormatter that rounds.
Expansion commands and state
core.rowGroups (GridRowGroupsApi):
| Command | Effect |
|---|---|
isActive() | Whether a hierarchy is bound. |
setExpanded(ids, expanded) | Expand or collapse the given groups; null targets every group. Returns RowGroupResult. |
toggle(id) | Flip one visible group; "unchanged" for a hidden or unknown id. |
setGrouping(grouping | null) | Regroup the resident flat rows with no query; null returns to them. Returns RowGroupingResult. |
RowGroupResult is { status: "applied" | "unchanged" | "unsupported" };
"unsupported" means no hierarchy, a provider without setExpanded or a
destroyed core. RowGroupingResult adds
{ status: "rejected", rejection }.
gridRef.current?.core?.rowGroups.setExpanded(null, true); // expand all, no remountA change of the view rows (a toggle, setGrouping, a regroup after a write) is
one batch: an open edit is committed, the row at the clip top keeps its viewport
offset, the frozen prefix is re-resolved, and the active cell stays on its row.
A collapse that hides the active cell moves it to the collapsed ancestor, in the
same column, and a row that no longer exists clears the selection. No query is
issued and unchanged records are not re-sorted.
grouping.getState() returns RowGroupingState = { expanded, collapsed, expandedDepth? }: the group ids toggled away from the expansion depth, and the
depth itself once setExpanded(null, …) moved it (expand all sets it to the
dimension count, collapse all to 0, and either forgets the toggled ids). Pass
it as initialState to a new createRowGrouping to restore it. Expansion is
kept by group id, so a group that survives a filter, a sort, a transaction or a
refresh keeps its state.
Writes
- A cell is writable when the source is writable and its row is a record row with a record. Group labels, aggregate cells and columnar leaves are read-only.
edit.starton a group or total row returnsfalsewith no event.- Paste and fill write the record rows and skip every group or total target,
each reported through
onWriteRejectedwithreason: "not-a-record";cells.setValueon such a row reports the same reason. - No row drag starts while a hierarchy is bound, and
rowDrag.commitreportsreason: "derived-view"withoperation: "row-move". CellWriteRejectedEvent.reasonis one of"read-only-source","not-a-record","derived-view"and"type-mismatch"here; a paste adds"not-editable"and history"conflict"and"missing-record", see Write commands and history.- One paste, fill or editor commit is one write command:
onWriteCommittedlists its changes by record id and the group rows it skipped, and an undo writes the records back through the same command, refolding or regrouping. - An editor commit is coerced by the column's
cellDataType, like paste, so an editednumbermeasure enterssumandavgas a number. A draft or typed value the column cannot hold writes nothing and is reported withreason: "type-mismatch",operation: "edit". - A measure edit refolds the aggregates on the edited row's path, the total row included.
- A dimension edit regroups. The record moves to its group, every collapsed
ancestor of its new position expands so it stays visible, and the active cell
follows the record. That expansion is recorded in
getState()and fires noonRowGroupToggled. - Copy reads cell values, so aggregates are copied.
onCellValueChangedrequiresgetRowId, also when the hierarchy comes from the data source.
A transaction on a mutable source (useGridData,
createMutableClientDataSource) that only updates records refolds and regroups
the records that changed, as an edit does, without a query; a transaction that
adds or removes records, or a refresh, rebuilds the grouping in one synchronous
pass and keeps surviving expansion, the active record and the anchor. A sort or
filter change rebuilds it too and returns to the top, as in a flat grid.
Rejections
A grouping that cannot apply is rejected: the grid renders the source's rows,
onRowGroupingRejected(rejection) is called and the console warns once per
reason ([gp-grid] rowGrouping rejected: <reason> (<field>); the grouping is not applied.).
RowGroupingRejection is { reason, field? }.
reason | Cause |
|---|---|
partial-source | Paginated loading, or a response with fewer rows than its totalRows. A page cache is never grouped as if it were the dataset. |
hierarchical-source | The source already returned a hierarchy. That hierarchy stays bound, so the grid is not flat. |
unknown-field | A dimension field or a measure's read field (source ?? field) is neither a declared column field nor a field of the columnar source. field names it. |
object-key | An object value in a dimension without toKey. field names the dimension. |
The callback fires on every load while the rejection applies, not once per
configuration. A dimension edit that would produce object-key keeps the groups
it had.
Limits
- The engine needs every row resident: a paginated source or a short response is
rejected (
partial-source). - Every source change (a transaction, a refresh, a sort, a filter, a dimension edit) rebuilds the grouping in one synchronous pass over the flat rows, about a second at 1,000,000 object rows. Updates proportional to the change are not supported.
- A toggle copies no leaf and rebuilds the visible index in O(visible groups): under 2 ms at 1,000 groups, about 30 ms at 500,000.
- Each group's aggregate state is retained, which makes a measure edit a path refold (under 4 ms at 1,000,000 rows) and costs memory at very high cardinality.
rowGroups.toggle(recordId)and other lookups of an arbitrary record id scan the flat rows.- A pinned label cell inside the frozen pin layer does not get the group row's background.
- Not included: full-width group rows, grouping flags on
ColumnDefinition, built-in date or number buckets, sticky total rows, group selection and record reordering in a grouped view. Remote, paged groups are server grouping.
For the complete API contracts and advanced examples, see the upstream row grouping and aggregation guide.