Grouping & Tree Data
Fold rows into expandable groups with aggregates, or nest them into a parent-child tree.
Row grouping organizes flat rows into an expandable folder-like structure based on identical column values.
Tree data instead nests rows using an explicit parent-child relationship. Grouping and aggregation are state (set
them in initialState, change them with setState, and a person changes them too); how a tree is derived is the
treeData option. Every row carries a hierarchy block — level, parentId,
hasChildren, expanded, childCount, leafCount, posInSet, setSize — so renderers never reconstruct it.
Row grouping
Set grouping.by to the columns to fold rows by, and aggregation.defs to compute summary values for each group.
By default group and total rows are ordinary cell rows: an auto hierarchy column, pinned left, shows each
group's toggle, key and row count, and every other column shows that group's aggregate through the column's own
format. Configure the hierarchy column with hierarchyColumn (header, width, indent, which parts show,
custom label and count) or turn it off with hierarchyColumn: false.
For a fully custom full-width group row instead, set grouping.display: 'row' and supply a groupRowRenderer:
import { useCallback, useMemo } from 'react';
import { Grid, GroupToggle, GroupCount, type ColumnDef, type GroupRenderContext } from '@witbybit/wit-grid-react';
interface EmployeeRow {
id: string;
name: string;
department: string;
salary: number;
}
export function GroupedEmployeesGrid({ data }: { data: EmployeeRow[] }) {
const columns = useMemo<ColumnDef<EmployeeRow>[]>(
() => [
{ field: 'id', header: 'ID', width: 100 },
{ field: 'name', header: 'Full Name', width: 180 },
{ field: 'department', header: 'Department', width: 150 },
{ field: 'salary', header: 'Salary', width: 120 },
],
[]
);
const groupRowRenderer = useCallback((ctx: GroupRenderContext<EmployeeRow>) => {
return (
<div style={{ paddingLeft: ctx.indentPx + 10, background: ctx.isStuck ? '#f5f5f5' : undefined }}>
<GroupToggle ctx={ctx} /> {ctx.label} <GroupCount ctx={ctx} /> · total {String(ctx.aggregates?.salary ?? '')}
<button onClick={ctx.expandAll}>Expand all</button>
</div>
);
}, []);
return (
<Grid
rows={data}
columns={columns}
getRowId={(row) => row.id}
initialState={{
grouping: { by: ['department'], display: 'row', rowHeight: 42, defaultExpanded: 1 },
aggregation: [{ colId: 'salary', aggFunc: 'sum' }],
}}
groupRowRenderer={groupRowRenderer}
/>
);
}Expansion
Group rows carry field, key, path, aggregates and their hierarchy. Which rows are open is the expansion state, keyed by visual row id:
api.runCommand('expand', {rowId}) / 'collapse' open and close one, 'expandAll' / 'collapseAll' every one, api.setState({expansion}) sets
them all (base: 1 opens the first level only), and api.isExpanded(id) reads one. grouping.defaultExpanded takes true, a number of open
levels, or a predicate over the group — and a default-open group can still be closed.
Total rows are configured with grouping.totals: groups places a total row at the 'top' or 'bottom' of every
group (or per level, with a function of the level), and grand adds one total over every row. They render with
totalRowRenderer. Built-in aggregations are sum, avg, min, max, count, distinctCount, first and
last; a custom aggFunc receives { colId, values, rows, scope, level, key }.
Tree data
For parent-child hierarchies (file trees, org charts, bill of materials), set treeData.getParentId, and
treeData.column to the field the hierarchy column shows — indented by level, with a toggle on parents. To draw
the tree in one of your own columns instead, read each row's hierarchy (its level and whether it has children)
in a cell renderer and set hierarchyColumn: false:
import { useMemo } from 'react';
import { Grid, type ColumnDef, type CellRendererProps, type GridOptions } from '@witbybit/wit-grid-react';
interface FileNode {
id: string;
name: string;
parentId?: string;
size?: string;
}
function TreeNameRenderer({ value, rowId, api }: CellRendererProps<FileNode>) {
const node = api.record(rowId)?.node;
if (!node) return null;
const { level, expandable, expanded } = node;
return (
<div style={{ paddingLeft: level * 20 }} onClick={() => void api.runCommand(expanded ? 'collapse' : 'expand', { rowId })}>
{expandable ? (expanded ? '📂' : '📁') : '📄'} {String(value)}
</div>
);
}
// The tree: an option, defined once.
const treeData: GridOptions<FileNode>['treeData'] = { getParentId: (row) => row.parentId, defaultExpanded: true };
export function FileDirectoryGrid({ nodes }: { nodes: FileNode[] }) {
const columns = useMemo<ColumnDef<FileNode>[]>(
() => [
{ field: 'name', header: 'Node Path / Name', width: 300, renderer: { kind: 'react', component: TreeNameRenderer } },
{ field: 'size', header: 'Capacity Size', width: 120 },
],
[]
);
return <Grid rows={nodes} columns={columns} getRowId={(row) => row.id} treeData={treeData} hierarchyColumn={false} />;
}Use grouping when groups come from repeated column values, and treeData when the hierarchy is already explicit
in the data; when both are set, grouping wins. With aggregation configured, tree parents carry the aggregate of
their descendants (treeData.aggregateParents, default on).
Hierarchy column and display modes
grouping.display | What you get |
|---|---|
'column' (default) | One hierarchy column, pinned left, showing every level indented. |
'columns' | One hierarchy column per grouping level (headed by that column), each showing its own level. |
'row' | A full-width row per group, drawn by groupRowRenderer (or grouping.rowRenderer). |
hierarchyColumn configures the auto column(s): header, width, minWidth, pinned (default true),
indentPerLevel (default 16), show: { toggle, checkbox, count }, and label(ctx) / count(ctx) /
cellClass to replace a part — each receives the row's kind, level, key or value, formatted value and
aggregates. hierarchyColumn: false removes it.
One context for every group renderer
Group rows (grouping.rowRenderer, or the React adapter's groupRowRenderer / totalRowRenderer), the hierarchy cell
(hierarchyColumn.renderer) and the stuck copy of a sticky header are all drawn with the same GroupRenderContext:
the fields above plus row, isTotal, isStuck, path, indentPx, label and count (what the built-in cell would
show), api, and actions bound to the row: toggle(), setExpanded(open), expandAll() / collapseAll() for the
group's subtree, and selectChildren(selected). ctx.selection is how much of the row is selected: 'all', 'some' or
'none' of the data rows beneath a group (collapsed ones included), so a renderer can draw its own tri-state control;
the renderer gets a new context when it changes. In a grouped grid the renderer draws group and total rows only: leaf
rows keep the built-in cell. Tree rows are data rows, so tree grids draw every row with it.
A renderer is either { kind: 'dom', renderer } (mount(container, ctx) returns { update?(ctx), destroy? } and runs
synchronously in the grid) or { kind: 'react', component }, rendered with the context as its props. A sticky header
that sticks or releases gets update with isStuck flipped, so a renderer can restyle it. hierarchyColumn.renderer
replaces the cell's content entirely (label, count and cellClass still apply); build it from the built-in parts
with renderGroupToggle(ctx) (DOM) or <GroupToggle ctx={ctx} /> and <GroupCount ctx={ctx} /> (React).
A complete React renderer with actions and a compact stuck layout. Memoize the config so a re-render does not reset the grid:
import { useMemo } from 'react';
import { Grid, GroupToggle, GroupCount, type GroupRenderContext } from '@witbybit/wit-grid-react';
function GroupRow(ctx: GroupRenderContext<Order>) {
const revenue = Number(ctx.aggregates?.revenue ?? 0);
if (ctx.isStuck) {
// Compact while stuck: just the toggle, the name and the total.
return (
<div className='stuck-row' style={{ paddingLeft: ctx.indentPx }}>
<GroupToggle ctx={ctx} /> {ctx.label} <strong>{revenue.toLocaleString()}</strong>
</div>
);
}
return (
<div className='group-row' style={{ paddingLeft: ctx.indentPx }}>
<GroupToggle ctx={ctx} /> {ctx.label} <GroupCount ctx={ctx} />
<strong>{revenue.toLocaleString()}</strong>
<button onClick={ctx.expandAll}>Expand below</button>
<button onClick={ctx.collapseAll}>Collapse</button>
<button onClick={() => ctx.selectChildren(ctx.selection !== 'all')}>{ctx.selection === 'all' ? 'Deselect' : 'Select all'}</button>
</div>
);
}
export function OrdersGrid({ orders }: { orders: Order[] }) {
const initialState = useMemo(
() => ({
grouping: {
by: ['region', 'country'],
display: 'row' as const,
stickyHeaders: true,
rowRenderer: { kind: 'react' as const, component: GroupRow },
},
aggregation: [{ colId: 'revenue', aggFunc: 'sum' as const }],
}),
[]
);
return <Grid rows={orders} columns={columns} getRowId={(r) => r.id} rowSelection='multiple' initialState={initialState} />;
}The same component works as hierarchyColumn.renderer for display: 'column' and 'columns'.
import { renderGroupToggle } from '@witbybit/wit-grid-core';
import type { GridOptions, GroupRenderContext } from '@witbybit/wit-grid-react';
// The `hierarchyColumn` option.
const hierarchyColumn: GridOptions<Order>['hierarchyColumn'] = {
renderer: {
kind: 'dom',
renderer: {
mount(container, ctx) {
const el = document.createElement('div');
const draw = (c: GroupRenderContext<Order>) => {
el.replaceChildren(renderGroupToggle(c), `${c.label} (${c.count ?? ''})`);
el.style.paddingLeft = `${c.indentPx}px`;
el.classList.toggle('is-stuck', c.isStuck);
};
draw(ctx);
container.append(el);
return { update: draw, destroy: () => el.remove() };
},
},
},
};A column's aggregate can be drawn instead of written: aggregateRenderer: { kind: 'dom', renderer } is mounted by
the grid into that column's cells on group and total rows, with { value, formattedValue, row, col }
(mount(container, params) returns { update?, destroy? }) — a sparkline, a progress bar, a badge.
Sticky group headers
grouping.stickyHeaders keeps expanded group headers in view while their rows scroll past:
import type { GridState } from '@witbybit/wit-grid-react';
const grouping: GridState<Order>['grouping'] = { by: ['region', 'country'], stickyHeaders: true };
// or tune it
const tuned: GridState<Order>['grouping'] = { by: ['region', 'country'], stickyHeaders: { levels: 1, shadow: false } };| Option | Meaning |
|---|---|
levels | How many grouping levels stick, outermost first. 1 sticks only top-level groups. Default: all. |
shadow | A shadow under the stuck stack. Default: true. |
How it behaves:
- Native. Headers use the browser's own sticky positioning, so a stuck header moves with the scroll on the GPU, with no JavaScript per frame.
- Stacking. One header per level stacks under the column header. Rows pinned to the top (the
pinnedRowsoption) sit above the stack, and pinned bottom rows stay at the bottom. - Push-up. When a group ends, its header is pushed up by the next group's header, as in a long contacts list.
- Stuck state. A header that is stuck carries the class
og-row-group-stuck, and a custom renderer seesctx.isStuck. - Every row model. Client, server-side and grouped server rows stick alike; a server group sticks once its rows have loaded.
.og-row-group-stuck {
background: #f8fafc;
font-weight: 600;
}Opening and closing groups
The chevron opens and closes a group. toggleOnRowClick lets a click anywhere on the group row do it too; the row's
own controls (its selection checkbox, buttons, links) keep their clicks. It is on by default for display: 'row',
where the row is the group's header, and off for the group column, where a click selects the cell. A stuck header
toggles the same way.
import type { GridState } from '@witbybit/wit-grid-react';
const across: GridState<Order>['grouping'] = { by: ['region'], display: 'row', stickyHeaders: true };
const inColumn: GridState<Order>['grouping'] = { by: ['region'], toggleOnRowClick: true };In a workspace, the Group menu of a grouped table sets the same options for the people using it: headers in the group column or across the row, kept in view or not, and every group expanded or collapsed. They are the grouping's own settings, so a saved view keeps them.
Keyboard, accessibility, selection and export
Group and total rows take focus like data rows. On the hierarchy cell, ArrowRight opens a closed row (or
moves into an open one's first child), ArrowLeft closes an open row (or moves to its parent) and Enter
toggles; group and total cells are never edited. While rows are grouped or tree-shaped the grid is a
treegrid: each row carries aria-level, aria-expanded, aria-posinset and aria-setsize.
The group checkbox (hierarchyColumn.show.checkbox) reflects and selects every row beneath the group, collapsed
ones included — api.getDescendantSelection(id) and api.setDescendantsSelected(id, selected) expose the same.
For tree data, treeData.selectDescendants makes selecting a parent select its whole subtree.
Copying a range that spans group or total rows copies what they show (the label and formatted aggregates).
api.exportExcel() exports grouped grids as collapsible Excel outlines. api.exportCsv() / api.getCsv() export the whole hierarchy — every group expanded — with group and total rows
(includeGroups, includeTotals) and the hierarchy column indented or as a path (hierarchyText).
See Events for groupingChanged, groupByChanged, groupColumnAdded/Removed/Moved,
aggregationChanged, treeDataChanged and expansionChanged.
Upgrading from 1.x
The hierarchy configuration changed shape; there is no compatibility layer.
| 1.x | Now |
|---|---|
groupBy: [...], groupRowHeight | grouping: { by: [...], rowHeight } |
showGroupFooter, footerRowRenderer | grouping.totals: { groups: 'bottom' }, totalRowRenderer |
enableStickyGroupRows | grouping.stickyHeaders |
getParentId, rowModelConfig.treeData | treeData: { getParentId, column, defaultExpanded, filterMode } |
masterDetailEnabled, detailRowHeight, detailRenderer | detail: { height, renderer, isMaster } |
setAggDefs([{ field, aggFunc }]) | aggregation: { defs: [{ colId, aggFunc }] } / api.setState({ aggregation: defs }) |
toggleGroupExpanded, isGroupExpanded, expandAllGroups, collapseAllGroups | the expand / collapse / expandAll / collapseAll commands, isExpanded |
toggleDetailExpanded, isDetailExpanded | the openDetail / closeDetail commands, isDetailOpen |
visualRow.depth / expanded / childCount, aggregateValues | visualRow.hierarchy.level / .expanded / .childCount, aggregates |
Group rows drawn by groupRowRenderer | Cell rows with a hierarchy column; grouping.display: 'row' for groupRowRenderer |
DefaultGroupRowRenderer and the other default React row renderers | Removed — the grid draws these rows itself |
Saved grid state from 1.x (schema v2) is not restored; the grouping part of saved state is now
grouping: { by, totals, stickyHeaders, defaultExpanded, display, toggleOnRowClick } (schema v3).
Last updated on