Grid Row grouping and aggregation
Row grouping and aggregation
Group rows by one or more columns, aggregate values, and control expansion.
Grouping folds rows into a tree while keeping one flat, virtualized index underneath. Selection, focus, copy, and streaming therefore keep the same bounded-range behavior they have while ungrouped. Grouping is a query over the row model: enable the panel to let people choose levels, or provide a controlled query.
The query is controlled here, so dragging a header onto the panel updates both the grid and the level list shown below it.
Quick start
Use the typed column helper so group keys, aggregate inputs, and aggregate outputs stay tied to the column tuple.
import { createColumnHelper } from "@pretable/core";
import { PretableSurface } from "@pretable/react";
type Order = {
id: string;
region: string;
owner: string;
amount: number;
};
const column = createColumnHelper<Order>();
const columns = [
column.accessor("region", { type: "text", header: "Region" }),
column.accessor("owner", { type: "text", header: "Owner" }),
column.accessor("amount", {
type: "number",
header: "Amount",
aggregate: "sum",
formatAggregate: ({ value }) =>
value === null ? "—" : `$${value.toLocaleString()}`,
}),
] as const;
export function OrdersGrid({ rows }: { rows: readonly Order[] }) {
return (
<PretableSurface
ariaLabel="Orders"
columns={columns}
getRowId={(row) => row.id}
groupPanel={{ enabled: true }}
rows={rows}
viewportHeight={480}
/>
);
}Open a column menu and choose Group by this column, or drag its header into the panel — the same panel shown in the example above, already grouped by desk. Active levels appear as ordered chips. Drag a chip to reorder levels; its remove button, Delete, or Backspace ungroups it. Arrow Left and Arrow Right reorder a focused chip.
The surface has role="grid" while ungrouped and role="treegrid" while grouped. The active chip strip is a listbox, and column actions use a menu. The panel consumes space inside viewportHeight, so enabling it does not change the surrounding bezel or page layout.
Control the grouping query
Rows mode can own the complete query, including grouping. query and onQueryChange are an exact pair: provide both, or omit both and let the row model own changes from menus, panel drags, sorting, and filtering.
import { useState } from "react";
import type { PretableQueryFor } from "@pretable/core";
function ControlledOrders({ rows }: { rows: readonly Order[] }) {
const [query, setQuery] = useState<PretableQueryFor<typeof columns>>({
filters: [],
sort: [],
rowGroups: [],
});
return (
<PretableSurface
ariaLabel="Orders"
columns={columns}
getRowId={(row) => row.id}
groupPanel={{ enabled: true }}
onQueryChange={setQuery}
query={query}
rows={rows}
viewportHeight={480}
/>
);
}rowGroups: [] is a controlled ungrouped state: user grouping persists only if onQueryChange publishes the returned complete query.
For explicit ownership, create a model with the same typed query and pass it to the surface:
const rowModel = createLocalRowModel({
rows,
columns,
query: {
filters: [],
sort: [],
rowGroups: [{ columnId: "region", direction: "asc" }],
},
});
<PretableSurface
ariaLabel="Orders"
columns={columns}
groupPanel={{ enabled: true }}
model={rowModel}
viewportHeight={480}
/>;Indexed group rows
snapshot.rowAt(index) and snapshot.range(start, end) return a discriminated "data" | "group" union. Group entries expose the typed group path, childCount, depth, and finalized aggregates. A collapsed group simply omits its descendants from the index; the snapshot never materializes a nested tree or a complete derived-row array.
While grouping is active, the surface derives one tree column for the group label, twisty, and child count. Configure it with groupColumn; it is presentation state, not a member of the model's typed column tuple.
Aggregates and number formatting
Numeric columns support "sum", "avg", "min", "max", and "count"; other values support "count". formatAggregate receives the inferred aggregate output separately from the leaf-row format callback — the panel example above already uses it on Shares and Market value.
When formatAggregate is absent, a numeric aggregate inherits the column's numberFormat for display and built-in clipboard output:
import { numberFormats } from "@pretable/react";
column.accessor("amount", {
type: "number",
aggregate: "sum",
numberFormat: numberFormats.money({ currency: "USD" }),
});Custom aggregators are mergeable reducers. init creates an accumulator, accumulate adds one leaf value, merge combines partial accumulators, and finalize creates the displayed value. Keep merge associative and deterministic.
import type { PretableAggregator } from "@pretable/core";
const weightedAverage: PretableAggregator<
Order,
number,
{ weightedTotal: number; weight: number },
number | null
> = {
init: () => ({ weightedTotal: 0, weight: 0 }),
accumulate: (accumulator, value, row) => ({
weightedTotal: accumulator.weightedTotal + value * row.amount,
weight: accumulator.weight + row.amount,
}),
merge: (left, right) => ({
weightedTotal: left.weightedTotal + right.weightedTotal,
weight: left.weight + right.weight,
}),
finalize: ({ weightedTotal, weight }) =>
weight === 0 ? null : weightedTotal / weight,
};Wired into a real column, that reducer is the only way to get a shares-weighted average onto a group row — the built-in "avg" preset can't do it, because it has no way to read a second field for weight. The example below puts both on the same grid so the two numbers visibly diverge, which is the actual proof the reducer works rather than a claim about it:
A mergeable init/accumulate/merge/finalize reducer computes a VWAP per group, shown beside the built-in avg preset on the same data so the two numbers visibly diverge.
Aggregates fold descendant leaf rows, never child aggregate values. By default they use post-filter descendants. Set aggregateFilteredRows to true to aggregate the full group population while displaying only matching descendants; childCount remains post-filter.
Expansion
Groups start expanded. Grouping is an interactive act as much as a configuration one — a user drags a column into the group panel while reading their rows — and collapsing on drop would hide the data they were just looking at. Pass initialExpansion to choose a different policy; { kind: "through-depth", depth: 0 } opens only the top level, which is the one to reach for when the grouped population is too large to draw at once.
Below, the grid is grouped two levels deep (desk, then sector) but constructed with { kind: "through-depth", depth: 0 }, so only the desk level starts open — click a twisty, or arrow onto a group row and press Arrow Right, to expand a sector and see the difference from the always-expanded grids above:
Two group levels seeded from the start, with sector groups collapsed by initialExpansion — click a twisty or use the arrow keys to expand one and reveal its positions.
Choose the initial policy when the rows-mode model is constructed:
<PretableSurface
ariaLabel="Orders"
columns={columns}
getRowId={(row) => row.id}
groupPanel={{ enabled: true }}
initialExpansion={{ kind: "through-depth", depth: 0 }}
rows={rows}
viewportHeight={480}
/>The policies are { kind: "collapsed" }, { kind: "expanded" }, and { kind: "through-depth", depth }. For an explicit model, use the same construction option, then call rowModel.setGroupExpanded(groupId, expanded), setExpansionDefault, expandAll, or collapseAll.
Group IDs are stable, collision-safe, and derived from the complete typed group path. They survive leaf updates and filtering. Reordering grouping levels changes those paths, so obsolete expansion overrides are discarded.
Keyboard
With focus on a group row, Arrow Left collapses an expanded group and Arrow Right expands a collapsed group. Enter and Space toggle expansion. In the grouping panel, Arrow Left and Arrow Right reorder the focused chip, while Delete or Backspace removes it. These commands preserve the same indexed focus and selection references used by ungrouped rows.
Group-column presentation
<PretableSurface
ariaLabel="Orders"
columns={columns}
getRowId={(row) => row.id}
groupColumn={{ header: "Group", widthPx: 260, pinned: "left" }}
groupPanel={{ enabled: true }}
hideGroupedColumns={false}
rows={rows}
viewportHeight={480}
/>hideGroupedColumns defaults to true; set it to false when original value columns should remain beside the tree column. The derived group column cannot itself become another grouping level.
Sorting under grouping
Sort entries order leaf rows within each group. A grouping entry's direction orders sibling group keys at that level. Because grouping, filters, and sort are one query, a controlled surface publishes them together through onQueryChange and an explicit model replaces them atomically with rowModel.setQuery(...).
Pretable does not currently expose tree-data input, pivoting, total rows, or per-chip aggregate selection. Grouping always derives a hierarchy from the active query and declared column aggregators.
See also
- Filtering — filter operators and aggregate population.
- Sorting — leaf ordering and group-level direction.
- Number formatting — locale-aware leaf and aggregate values.
<PretableSurface>— complete surface props.- Grid API reference — query, expansion, and aggregate types.