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.displayWhat 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 } };
OptionMeaning
levelsHow many grouping levels stick, outermost first. 1 sticks only top-level groups. Default: all.
shadowA 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 pinnedRows option) 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 sees ctx.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.xNow
groupBy: [...], groupRowHeightgrouping: { by: [...], rowHeight }
showGroupFooter, footerRowRenderergrouping.totals: { groups: 'bottom' }, totalRowRenderer
enableStickyGroupRowsgrouping.stickyHeaders
getParentId, rowModelConfig.treeDatatreeData: { getParentId, column, defaultExpanded, filterMode }
masterDetailEnabled, detailRowHeight, detailRendererdetail: { height, renderer, isMaster }
setAggDefs([{ field, aggFunc }])aggregation: { defs: [{ colId, aggFunc }] } / api.setState({ aggregation: defs })
toggleGroupExpanded, isGroupExpanded, expandAllGroups, collapseAllGroupsthe expand / collapse / expandAll / collapseAll commands, isExpanded
toggleDetailExpanded, isDetailExpandedthe openDetail / closeDetail commands, isDetailOpen
visualRow.depth / expanded / childCount, aggregateValuesvisualRow.hierarchy.level / .expanded / .childCount, aggregates
Group rows drawn by groupRowRendererCell rows with a hierarchy column; grouping.display: 'row' for groupRowRenderer
DefaultGroupRowRenderer and the other default React row renderersRemoved — 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).

Edit on GitHub

Last updated on

On this page