Grouping & aggregation

Roll rows up by one or more columns and compute aggregates (sum, avg, count, min, max, custom) at each group level. Powered by columnGroupingFeature plus per-column aggregate config.

Flat rows are grouped by one or more fields, aggregated with sum, average or count, then optionally pivoted across rows and columns.

Try it: drag a column into the group-by lane, then change aggregators per column:

Open the live example: Grouping + aggregation (Sorting & Grouping)

Minimal example

The examples on this page run against these rows:

<script lang="ts">
  import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'

  type Person = {
    id: number
    name: string
    department: string
    city: string
    age: number
    salary: number
  }

  const people: Person[] = [
    { id: 1, name: 'Ada Lovelace',   department: 'Engineering', city: 'London',   age: 36, salary: 142000 },
    { id: 2, name: 'Grace Hopper',   department: 'Engineering', city: 'New York', age: 45, salary: 168000 },
    { id: 3, name: 'Linus Torvalds', department: 'Platform',    city: 'Portland', age: 54, salary: 155000 },
    { id: 4, name: 'Radia Perlman',  department: 'Networking',  city: 'Seattle',  age: 49, salary: 161000 },
    { id: 5, name: 'Barbara Liskov', department: 'Platform',    city: 'Boston',   age: 52, salary: 172000 },
  ]

  const columns: GridColumns<Person> = [
    { field: 'name',       header: 'Name',       width: 190 },
    { field: 'department', header: 'Department', width: 150 },
    { field: 'city',       header: 'City',       width: 130 },
    { field: 'age',        header: 'Age',        width: 80 },
    { field: 'salary',     header: 'Salary',     width: 130, format: { type: 'currency', currency: 'USD' } },
  ]
</script>
<script lang="ts">
  import {
    SvGrid, tableFeatures, rowSortingFeature, columnGroupingFeature,
    type ColumnDef,
  } from '@svgrid/grid'

  type Employee = {
    id: number; name: string; department: string;
    salary: number; performance: number
  }

  const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })

  const columns: ColumnDef<typeof features, Employee>[] = [
    { field: 'department', header: 'Department' },
    { field: 'name',       header: 'Name' },
    { field: 'salary',     header: 'Salary',
      aggregate: 'sum',
      format: { type: 'currency', currency: 'USD' } },
    { field: 'performance', header: 'Performance',
      aggregate: 'avg' },
  ]

  const rows: Employee[] = [
    { id: 1, name: 'Ada',   department: 'Engineering', salary: 180_000, performance: 4.8 },
    { id: 2, name: 'Linus', department: 'Engineering', salary: 195_000, performance: 4.6 },
    { id: 3, name: 'Grace', department: 'Operations',  salary: 165_000, performance: 4.9 },
  ]
</script>

<SvGrid
  data={rows}
  columns={columns}
  features={features}
  groupBy={['department']}
/>

The grid emits one group row per unique department value, with the group cell showing the rolled-up salary sum + average performance. Click the chevron on a group row to expand its children.

Setting the group-by

Three ways, ranked by ergonomic order:

  1. The column menu. When the user opens a header's menu, "Group by this column" toggles that column in/out of the group-by list.
  2. The groupBy prop. Seeds the group-by list and re-applies whenever the prop's own value changes, so it works both as initial state and as a controlled value. A group-by set from the menu or the API is not clobbered while the prop stays put.
  3. The imperative API. api.setGroupBy(['department', 'role']) for toolbars / saved views.

groupBy is ignored when treeData is set - a row cannot be both a hierarchy node and bucketed under a group banner.

The group order is significant - ['region', 'country'] rolls up country inside region; reverse the array to flip the hierarchy.

Built-in aggregators

Aggregator Returns Behaviour on empty groups
'sum' Sum of numeric cell values 0
'avg' Arithmetic mean (with safe divide-by-zero) null
'count' Number of leaf rows 0
'min' Smallest value (numeric or Intl.Collator-comparable) null
'max' Largest value null

'sum' / 'avg' / 'min' / 'max' cast values to Number. If the column has non-numeric values mixed in, those rows are skipped.

Custom aggregator

Pass a function instead of a string for any group-aware computation:

{
  field: 'orders',
  header: 'Top customer',
  aggregate: (rows) => {
    const top = rows.reduce<Employee | null>(
      (acc, r) => !acc || r.orders > acc.orders ? r : acc,
      null,
    )
    return top?.name ?? '-'
  },
}

The callback gets every leaf row in the group (already filtered). Return whatever the cell should display - string, number, or a formatted value.

Custom group cell rendering

By default the group cell shows key (n) - e.g. "Engineering (12)". Override via the column's cell template:

{#snippet GroupCell(props: { row: GroupRow<Employee> })}
  <span class="font-semibold">
    {props.row.groupKey}
    <span class="text-sm opacity-60">({props.row.subRows.length} reports)</span>
  </span>
{/snippet}

The row.groupKey is the unique group value (the department name in the example). row.subRows is the children. row.depth is the nesting level (useful for indentation when you group by multiple columns).

Aggregating string columns

Strings work with 'count', 'min', 'max', and any custom aggregator. For sum / avg you'll get NaN because the cast to Number fails - the grid renders this as - by default.

A useful custom aggregator for strings:

{
  field: 'tags',
  aggregate: (rows) => {
    const set = new Set<string>()
    for (const r of rows) for (const t of r.tags) set.add(t)
    return Array.from(set).join(', ')
  },
}

Performance

Aggregation runs once per group-by change, NOT per scroll frame. The cost is O(n) for count / sum / avg, O(n log n) for min / max because the engine sorts to find the extreme.

For a 100k-row dataset grouped by two columns with three aggregators, the pipeline adds ~36 ms to the initial paint (see Performance benchmarks). After that, scroll is unaffected - the renderer hands each visible group its precomputed value.

Group expansion state

expanded is owned by the engine by default; you can hoist it for saved-views purposes:

<SvGrid
  ...
  expanded={controlledExpanded}
  onExpandedChange={(next) => (controlledExpanded = next)}
/>

onExpandedChange fires for every path that changes expansion: a click on a group banner, api.setRowExpanded(), and api.expandAllGroups() / api.collapseAllGroups(). It receives the full next map, so writing it straight back into expanded (as above) is safe and will not loop.

The shape is Record<rowId, boolean>. Group row ids are built from the grouping path rather than the display label - grouping by department gives group_department_Engineering, and adding role beneath it gives group_department_Engineering_role_Senior. Tree rows key off the engine's row id instead, so set getRowId if you want those keys to be your own ids. Capture the map from onExpandedChange rather than hand-building the keys.

Group sort vs leaf sort

The sort UI sorts within the active sort scope:

To sort the groups themselves by their rolled-up value, set the sort on the aggregated column AFTER setting the group-by. The grid recognises that the column is aggregated and sorts the group rows instead of the leaves.

Filtering vs grouping

Filters run BEFORE grouping (see Architecture for the pipeline order). The aggregator only sees rows that passed the filter. This is what makes "department salary sum, filtered to active employees only" work without any extra config.

Pivot vs group-by

When the question is "group by row dimensions, also group by column dimensions, also pick aggregators per measure" - that's a pivot. The pivot helpers build a different data structure optimised for that shape. Use group-by when you only roll up rows; use pivot when you also roll up columns.

Display modes

groupDisplayMode decides where group state is drawn:

Mode Result
groupRows (default) A full-width banner row per group. Unchanged behaviour.
singleColumn One synthetic Group column holding every level, indented by depth.
multipleColumns One synthetic column per grouped field.
<SvGrid {data} {columns} groupable groupDisplayMode="singleColumn" />

Both column modes hide the grouped source columns, because their values move into the auto column - showing both would just duplicate them. They also render the group row as an ordinary row, which is the real reason to use them: its aggregate cells then line up under the columns they belong to instead of sitting in a full-width strip.

Tune the combined column with autoGroupColumnHeader (default "Group") and autoGroupColumnWidth (default 220). In multipleColumns each column takes its name from the source column's header.

Group footers (subtotal rows)

groupFooters closes each group with a subtotal row:

<SvGrid {data} {columns} groupable groupFooters />

The footer is a clone of the group banner, so it already carries that group's aggregates and renders through the normal cell path - each total lands under its own column instead of in a full-width strip. It is not expandable and has no expander.

Only columns with an aggregate produce a value, the same ones that populate the banner.

Grand total row

grandTotalRow appends a single totals row for the whole filtered set:

<SvGrid {data} {columns} grandTotalRow />

It is independent of groupFooters - use it on a flat grid for a bottom totals line, or together for subtotals and a total:

<SvGrid {data} {columns} groupable groupFooters grandTotalRow />

Three things to know:

Columns without an aggregate render blank, and if no column declares one the row is skipped entirely.

Grouping with pagination

pageSize budgets data rows. Group banners and footers do not count against it:

<SvGrid {data} {columns} groupable groupFooters pageable pageSize={10} />

A page holds pageSize real rows and reprints the banners those rows sit under, so a group split across a page boundary is labelled on both pages - the way a spreadsheet repeats group headers across a page break. Footers are inserted after paging, so switching them on never pushes data onto the next page.

A collapsed group is the visible unit and takes one page slot itself; an expanded one is a header and takes none.

Frequently asked questions

How do I group rows in SvGrid?

Register columnGroupingFeature and group by one or more columns. Each group renders a collapsible header row, and you attach an aggregate per column to compute sum, avg, count, min, max, or a custom reducer at every group level.

What aggregation functions does SvGrid support?

Built-in sum, avg, count, min, and max, plus custom aggregators - any function that reduces a group's rows to a single value. Aggregates compute at each group level and at the grand-total footer.

Is grouping the same as a pivot table?

No. Grouping rolls rows up along the row axis. A pivot table also spreads a field across the column axis with nested headers - that is the @svgrid/enterprise pivot model. See Pivot tables for the column-axis version.

More examples

Group display modes + footers

Switch between groupRows banners, a single combined Group column, and one column per grouped field. groupFooters closes each group with a subtotal row under the real columns. Paging counts DATA rows, so pageSize means what it says: a page reprints the banners its rows sit under, and footers never eat the budget.

Open the live example: Group display modes + footers (Sorting & Grouping)

Group panel (drag & drop)

DevExpress / Kendo-style Group Panel: drag chips into the panel to group, drag inside to reorder grouping levels, × to ungroup. Drives api.setGroupBy() under the hood.

Open the live example: Group panel (drag & drop) (Sorting & Grouping)

Try it

Grouping is a prop, and the aggregate shown on each group header comes from the column. Add summary and the same reducers total the whole filtered set in the footer.

<script lang="ts">
  const agg: GridColumns<Person> = [
    { field: 'name',       header: 'Name',       width: 190 },
    { field: 'department', header: 'Department', width: 150 },
    { field: 'city',       header: 'City',       width: 130 },
    { field: 'age',        header: 'Age',        width: 80,  aggregate: 'avg', summary: 'avg' },
    { field: 'salary',     header: 'Salary',     width: 130, aggregate: 'sum', summary: 'sum',
      format: { type: 'currency', currency: 'USD' } },
  ]
</script>

<SvGrid data={people} columns={agg} groupBy={['department']} groupable summary sortable />

Two levels deep

groupBy is an array, and the order is the nesting order. Every level gets its own aggregate row, computed over the leaf rows beneath it rather than over the level above.

<script lang="ts">
  const nested: GridColumns<Person> = [
    { field: 'name',   header: 'Name',   width: 190 },
    { field: 'age',    header: 'Age',    width: 80,  aggregate: 'avg' },
    { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum',
      format: { type: 'currency', currency: 'USD' } },
  ]
</script>

<SvGrid data={people} columns={nested} groupBy={['department', 'city']} groupable summary />

See also

Live examples

  • Grouping + aggregation - Group by department, sum salaries, average performance, expand/collapse keys.
  • Group display modes + footers - Switch between groupRows banners, a single combined Group column, and one column per grouped field. groupFooters closes each group with a subtotal row under the real columns. Paging counts DATA rows, so pageSize means what it says: a page reprints the banners its rows sit under, and footers never eat the budget.
  • Group panel (drag & drop) - A Group Panel: drag chips into the panel to group, drag inside to reorder grouping levels, × to ungroup. Drives api.setGroupBy() under the hood.

Related articles