Skip to content

React table row grouping — single-level groupBy, per-group aggregates

Try it live: open a Mantine starter in StackBlitz — this page’s feature is already wired in src/App.tsx (groupBy="role" + groupAggregates); edit it in the browser, no install. Other UI kits →

See it working: collapse groups and read per-group subtotals in the live demo — a real table you can click, not a recording.

Group rows by one column with groupBy and optional per-group subtotals via groupAggregates — the same mapper signature as summaryRow. Omit groupBy and the table never inserts group header rows (package DNA: opt-in).

import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled
interface Person {
id: string;
name: string;
team: string;
budget: number;
}
const PEOPLE: Person[] = [
{ id: "1", name: "Aisha", team: "Core", budget: 42_000 },
{ id: "2", name: "Jonas", team: "Platform", budget: 38_000 },
{ id: "3", name: "Mei", team: "Core", budget: 51_000 },
];
export function People() {
return (
<DataTable
data={PEOPLE}
columns={[
{ key: "name", sortable: true },
{ key: "team", sortable: true },
{
key: "budget",
accessor: (r) => `$${r.budget.toLocaleString()}`,
sortValue: (r) => r.budget,
},
]}
rowKey={(r) => r.id}
groupBy="team"
groupAggregates={(rows) => ({
budget: (
<b>${rows.reduce((sum, r) => sum + r.budget, 0).toLocaleString()}</b>
),
})}
/>
);
}
  • Opt-in. Pass groupBy (a column key) or set source.groupBy via useFrontendData / URL state — without it, grouping stays fully dormant.
  • Single level. One grouping column at a time (no nested groups, no drag-to-group panel).
  • Frontend tier only. Grouping needs the full filtered row set in memory (allFilteredRows). Server-paginated sources log a dev-mode warning and ignore grouping — see Data tiers.
  • Shared mapper. groupAggregates(rows) uses the same (rows) => Partial<Record<string, ReactNode>> shape as summaryRow; reuse one function for both if the math is identical.
  • Expand / collapse. Groups start expanded. Collapse state is ephemeral (not URL-synced). groupBy itself serializes to the URL like sort and filters.
  • Selection. When row checkboxes are enabled, each group header exposes a tri-state checkbox over its leaf rows.
Prop / fieldTypeDefaultDescription
groupBystring | nullColumn key to group by; its presence arms grouping (still requires a frontend data source).
onGroupByChange(groupBy: string | null) => voidControlled change channel; falls back to source.setGroupBy.
groupAggregates(rows: readonly TRow[]) => Partial<Record<string, ReactNode>>Per-group cells — same signature as summaryRow. Omit for headers without subtotals.
collapsedGroupIdsreadonly string[]Controlled collapsed group keys (ephemeral — not URL-synced).
onCollapsedGroupIdsChange(ids: string[]) => voidControlled collapse channel; uncontrolled mode uses internal state.
labelsTableLabelsEnglishOverride expandGroup, collapseGroup, and groupCount for header controls.
  • Bucketing uses the column’s sortValue when present, otherwise a path lookup on the column key — never the JSX accessor.
  • Works on desktop rows and mobile cards, LTR and RTL, with and without virtualize (virtual windows count collapsed groups as one row).
  • Out of scope (by design): multi-level nesting, pivot mode, drag-to-group, and Excel-style aggregation pickers.
  • Ant Design maps group headers onto its high-level Table via custom row rendering; every other kit renders native group header rows/cards.

See it live in the demo — rows are grouped by team with a budget subtotal per group.