Skip to content

Components

Grouped Tables With Subtotals — Group, Reduce, Render, Don't Reach for a Pivot Library

One groupBy, One Reducer Per Column, Sticky Group Headers — a Report, Not a Pivot Engine
Written by Aiden Wood Founder and Lead Design Engineer
🧮 📁 ∑

"Can we see the deals grouped by region, with totals per region?" is the most common table request in any ops tool — and the moment teams hear "grouping" they reach for a pivot-grid library sized for a BI product. For one grouping level and a subtotal row, that's a dependency doing a reduce.

Velocity X's standard-grouped-summary-table is the one-level version done properly: group header rows, per-group aggregates, a grand-total footer, collapsible groups.

The Money Pattern

Grouping is a Map, aggregation is a reducer per column — declared next to the column, same philosophy as sort comparators:

const groups = useMemo(() => {
  const m = new Map<string, Row[]>();
  for (const r of rows) {
    const k = r[groupKey];
    m.set(k, [...(m.get(k) ?? []), r]);
  }
  return [...m.entries()].map(([key, items]) => ({
    key,
    items,
    totals: Object.fromEntries(
      columns.filter(c => c.aggregate)
             .map(c => [c.key, c.aggregate!(items)])
    ),
  }));
}, [rows, groupKey]);

// column: { key: 'value', aggregate: rs => rs.reduce((a, r) => a + r.value, 0) }

Render is then a flat map: group header row (sticky, so the context survives scrolling), item rows, subtotal row. The aggregate being a function per column — not a hardcoded sum — is what lets one column average while another counts distinct, which every real report needs by week two.

The Catch

Subtotals must be computed from the same filtered dataset the rows come from. The classic bug: a quick-filter hides rows but the subtotal still reflects the full group, and now the table visibly doesn't add up — which reads as "this app can't do math" to the exact audience (finance, ops) this table serves. Derive groups after filtering, always.

Semantics matter in a data table: group headers are <th scope="row"> rows, subtotal rows are marked up as such, and collapsed groups use aria-expanded on the toggle. Screen-reader users read reports too, and a div-soup pivot is unreadable to them.

The Verdict

One grouping level + subtotals is a Map and a reducer, not a pivot engine — save the heavy library for when someone genuinely asks for drag-to-pivot. The finished table is in Velocity X's Standard Data category with the sortable and inline-editable tables it shares a column model with.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.