Multi-level grouping and group summaries
Grouping itself is Community, and so is the group panel, and so are footer column totals. Enterprise adds exactly two things:
- a second and further grouping level,
- those same totals repeated on every group header.
Worth knowing before you reach for a licence key: if one level is enough, you already have everything.
One level — Community
import { DataGrid } from '@kanunilabs/datagrid-react';
<DataGrid
dataSource={orders}
rowKey="id"
columns={columns}
grouping={{ groupBy: 'region', panel: true }}
summaries={[
{ columnId: 'units', type: 'sum' },
{ columnId: 'revenue', type: 'sum' },
]}
/>
panel: true puts the drop strip above the grid; dragging a header into it
groups by that column. summaries draws the totals in the footer.
More levels — Enterprise
import { EnterpriseDataGrid } from '@kanunilabs/datagrid-react-enterprise';
<EnterpriseDataGrid
licenseKey={key}
dataSource={orders}
rowKey="id"
columns={columns}
grouping={{
groupBy: ['region', 'category', 'rep'], // outermost first
panel: true,
groupSummaries: true,
collapsedByDefault: true,
}}
summaries={[
{ columnId: 'units', type: 'sum' },
{ columnId: 'revenue', type: 'sum' },
]}
/>
| Option | Type | Default |
|---|---|---|
grouping.groupBy | string | string[] | null | null |
grouping.panel | boolean | false |
grouping.groupSummaries | boolean | false |
grouping.collapsedByDefault | boolean | false |
grouping.showGroupedColumns | boolean | false |
grouping.display | 'fullWidth' | 'aligned' | 'column' | 'fullWidth' |
grouping.groupColumn | { headerName?, width?, minWidth? } | — |
grouping.stickyHeaders | boolean | false |
groupSummaries: true on its own shows nothing. The group header reuses the
summaries array, so an empty one produces empty headers. There is no separate
per-level or per-group definition list.
Totals land inside the header strip — ▸ Category: Toys (12) · Revenue: 1,240 — not in a footer row beneath the members, even though the header menu
item is worded "Show group footers".
Summary types are sum, avg, min, max, count and custom. avg,
min and max skip blanks; count counts non-empty cells.
summaries={[
{
columnId: 'margin',
type: 'custom',
calculate: (cells) => cells.reduce((a, c) => a + Number(c.value ?? 0), 0) / cells.length,
},
]}
Inside a group header, calculate receives only that group's cells.
Group rows aligned to the columns
By default a group row is one banner across the grid, with its summaries written out after the label ("Sum Revenue: 11,733"). For a report you read down the columns, put each summary under its own column instead:
grouping={{ groupBy: 'region', groupSummaries: true, display: 'aligned' }}
The group row then becomes a row of cells. The group's caption, value, count
and toggle sit in the first visible column, indented by level. Each column
that has a group summary shows it in its own cell, formatted like the footer.
If a column has two summaries (sum and avg), the cell shows the first one
declared. A click still opens and closes the group, the row is still not
selectable, and its box in the checkbox column ticks every row under it.
Both the React and the JavaScript grid support it. 'fullWidth' is the
default.
Editing a group total
Enterprise. In a plan or a budget the number people change is often the
subtotal: "North gets 80 units, not 40". Give the column groupEdit and the
subtotal under it becomes editable:
<EnterpriseDataGrid
editing={{ mode: 'cell' }}
grouping={{ groupBy: 'region', groupSummaries: true, display: 'aligned' }}
summaries={[{ columnId: 'units', type: 'sum' }]}
columns={[
{ field: 'region' },
{ field: 'units', dataType: 'number', groupEdit: 'proportional' },
]}
/>
Double-click the subtotal in a group row, type the new total, press Enter; Escape keeps the old one. The new total is shared out over every row in the group — nested groups included, open or closed:
groupEdit | What the rows get |
|---|---|
'proportional' | Each row keeps its share: 10 and 30 of 40 become 20 and 60 of 80. A group adding up to zero is split evenly. |
'even' | Every row the same: 80 over 4 rows is 20 each. |
| a function | Whatever it returns. It gets groupKey, columnId, aggregate, target and the group's rows (with their value and whether they may change) and returns { rowKey, value }[]. |
It works where the column's subtotal is a sum or an avg (for an average,
the rows move so their average is what you typed), and only in group rows that
show subtotals under their columns — display: 'aligned' or 'column' with
groupSummaries on. Whole numbers stay whole: when every row and the new total
are whole, the remainder goes to the rows with the largest fractions, so the
rows add up to exactly the total typed.
The change goes through editing like any other: one step on the undo stack
(Ctrl+Z takes back the whole share-out), rows the user may
not change (editable: false, allowUpdating) keep their value and the others
take the difference, and in batch mode the rows wait for Save. A typed total is
read the way a paste is, so "1.234,5" and "1,234.5" both work.
Group column
With display: 'column' the group rows get a column of their own at the start
of the grid:
grouping={{ groupBy: ['region', 'city'], display: 'column', groupSummaries: true }}
The column holds each group row's toggle, value and count, indented by level.
The rest of the group row is laid out as in 'aligned': each group summary sits
under its own column. A record's cell in the group column is empty, since the
group header above it already gives the value.
- Header. The grouped columns' names, "Region › City". Set
groupColumn: { headerName: 'Group' }to name it yourself. - Width. 200 px by default, at least 80 (
groupColumn.width,minWidth). It resizes and auto-fits like any column, and the width is kept ingetViewState(). - Sorting. A click on the header turns every grouping level around at once, A→Z to Z→A. In single-sort mode a click on another header replaces that sort, as any header click does, and the groups go back to A→Z.
- Pinning. It is pinned left, before any column you pinned there, so it stays in view while the grid scrolls sideways.
- What it does not have. No filter, no header menu, no total in the footer row. You cannot hide or move it. It exists only while rows are grouped.
- Where it is left out. Copy, editing, the chart and the AI state leave it out like the grid's other own columns.
- Export. It becomes the first column of an export: the group label on group rows, blank on records.
- Where it works. In Community, in the React and the JavaScript grid. Grouping by two or more levels is still Enterprise.
Sticky group headers
Scroll into a long group and its header leaves the screen with the first rows. Keep it in view:
grouping={{ groupBy: ['region', 'city'], stickyHeaders: true }}
While you scroll, the headers of the groups the top row belongs to stay under the column header, outermost first: a region, then the city inside it. When the next group arrives, its header pushes the old one up and out. A sticky header is the same row as the real one, with the same summaries, the same checkbox and the same aligned cells. Clicking it opens or closes the group. Closing a group from its sticky header also scrolls the group's real header into view, so you do not land on whatever rows moved up into its place.
Keyboard navigation accounts for it: moving the focus up to a row stops short of the headers that will cover it. The sticky copies are hidden from screen readers and out of the tab order; the real headers stay in the row list. Both the React and the JavaScript grid support it, in Community. It is off by default. It works with client data only, because a remote grid does not hold the rows between a header and the top of the screen.
Dates by day, week, month, quarter or year
A date column groups by its exact value unless it says otherwise — a year of
orders makes hundreds of groups. groupInterval groups it by calendar unit:
{ field: 'placed', dataType: 'date', groupInterval: 'month' } // 'day' | 'week' | 'month' | 'quarter' | 'year'
The banners read in the grid's language ("October 2026", "Q4 2026"; a week
shows its ISO number and the Monday it starts on), in date order, blanks last.
The group's key is the language-free bucket — placed::2026-10 — so which
groups are collapsed survives a language switch and a saved view. Dates are
local calendar dates; a bare "2026-10-03" string is that calendar day, not
UTC midnight. It applies to client-side data: a server-side source groups the
way its server does.
Reacting to it
controller.events.on('groupChanged', ({ groupBy, levels }) => {
// groupBy = outermost level, levels = every level
});
columnStateChanged fires too, because the grouped column leaves the data area
and comes back when grouping is removed.
From code: controller.groupBy(id), controller.setGroupSummaries(true),
controller.expandAllGroups(), controller.collapseAllGroups().
What this does not do
- Community trims silently. Pass three levels to
DataGridand you get the first one;groupSummariesis forced tofalse. The warning is printed only underdebugMode: true. Adding a second level in the Community UI replaces the first rather than nesting — a deliberate swap, not a no-op. - Group keys are
String(value). Apart from date buckets (groupInterval, above) there is nogroupValueGetter. Two values with the same text (1and'1') merge into one group;nullandundefinedcollect under a localised "(Blanks)". valueFormatteris not applied to the header caption. A formatter takes a row, and a group strip has no single row.lookupcolumns are the exception — those resolve to the label rather than the code.- A banner subtotal is not editable. With the default
display: 'fullWidth'the subtotals are text in one strip, so there is no cell to type in — use'aligned'or'column'for editing a group total. - The runtime "group footers" toggle is React-only. The vanilla header menu
has no such item; call
controller.setGroupSummaries(...)yourself. - Grouping outranks the user's sort. Group columns are pushed to the front of the sort, so a user's own sort becomes the secondary key. They can still flip a group column's direction, and that is kept.
- One group column, not one per level.
display: 'column'puts every level in a single column, indented. A column per level is not available, and a record does not repeat its group values in the group column. Withdisplay: 'aligned'the name sits in the first visible column and scrolls sideways with it; the group column is pinned and does not. - A group header is not a selectable row. Space on a focused
header opens or closes it. With
selection: { mode: 'multiple', checkboxColumn: true }the banner carries a checkbox that ticks every row under the group — open or collapsed, nested or not — and shows all / some / none; from code,controller.toggleGroupSelection(key),setGroupSelected(key, on)andgetGroupSelectionState(key). groupable: falseonly closes the UI. The header menu item is disabled and the column drops out of the panel list, butcontroller.groupBy('thatOne')still works.- Export lays a group row out like the aligned display. The first column
holds
"<label> (<count>)"; withgroupSummaries, each subtotal sits under its own column (the first summary declared for it), formatted as on screen and, in Excel, written as a number you can still sum. A summary on the FIRST column has no cell of its own there and is not exported. Styled Excel export turns nesting into real outline levels; PDF only indents. - Group totals lag unsaved edits. Footer totals read pending edits; the cached group tree does not, so a group header can show the old number until the edit is committed.
- Tree wins over grouping. Configure both and
groupByis ignored, warned once underdebugMode. - Server-side grouping is the server's job. With a
DataSourceobject the client computes nothing: returngroups: RemoteGroupRow[]keyed ongroupKeyslength, with aggregates under${columnId}:${type}.type: 'custom'is never sent (a JS reducer cannot run there).expandAllGroups()opens every level without fetching the table: groups open as they arrive, and only the groups the viewport reaches ask for their children. Closing one afterwards keeps it closed;collapseAllGroups()closes them all again.