gp-grid-logo
Guides

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",
});
RowGroupingConfigDefaultMeaning
dimensionsrequiredOrdered RowGroupDimensions: { field, id?, toKey? }. id defaults to field and names the bucketing.
measuresnoneRowGroupMeasures: { 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.
defaultExpandedDepth0Groups of a depth below it start expanded; 0 collapses every group.
grandTotalnone"top" or "bottom" adds one total row; omitted adds none.
initialStatenoneA 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 query

A 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

aggregateResultNull rules
"sum"Sum of the finite numbersnull when the group has none; non-numbers, NaN and ±Infinity are skipped
"avg"Sum divided by the count of finite numbersnull when the group has none
"count"Count of non-null values0 for none
"min" / "max"Extremum of the non-null values, by compareValuesnull when every value is null
RowGroupAggregatorresult(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 leave from intact, 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):

CommandEffect
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 remount

A 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 = &#123; expanded, collapsed, expandedDepth? &#125;: 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.start on a group or total row returns false with no event.
  • Paste and fill write the record rows and skip every group or total target, each reported through onWriteRejected with reason: "not-a-record"; cells.setValue on such a row reports the same reason.
  • No row drag starts while a hierarchy is bound, and rowDrag.commit reports reason: "derived-view" with operation: "row-move".
  • CellWriteRejectedEvent.reason is 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: onWriteCommitted lists 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 edited number measure enters sum and avg as a number. A draft or typed value the column cannot hold writes nothing and is reported with reason: "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 no onRowGroupToggled.
  • Copy reads cell values, so aggregates are copied.
  • onCellValueChanged requires getRowId, 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? }.

reasonCause
partial-sourcePaginated loading, or a response with fewer rows than its totalRows. A page cache is never grouped as if it were the dataset.
hierarchical-sourceThe source already returned a hierarchy. That hierarchy stays bound, so the grid is not flat.
unknown-fieldA 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-keyAn 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.

On this page