Grouping and aggregation
Group rows by a column and each group becomes an expandable row with a count and, in every other column, an aggregate of its members: a sum of amounts, a range of dates, a count of distinct values. Each column kind brings a sensible aggregation; a footer can show the grand total.
| 8 | 4 | 2 distinct | $5,059.00 | 64 | 30.5% | May 2, 2023 – Sep 8, 2026 | |
| Meera Sharma | Free | Active | $0.00 | 1 | 0.5% | Jun 26, 2024 | |
| Sofia Rossi | Enterprise | Active | $1,659.00 | 21 | 61.3% | Mar 26, 2026 | |
| Zara Sharma | Free | Invited | $0.00 | 1 | 59% | Sep 8, 2026 | |
| Vihaan Prabhakaran | Free | Invited | $0.00 | 1 | 3.4% | Dec 8, 2024 | |
| Meera Kim | Free | Active | $0.00 | 1 | 24.1% | Oct 23, 2025 | |
| Ishaan Dubois | Team | Active | $1,856.00 | 64 | 63% | Apr 6, 2026 | |
| Lucas Bose | Team | Invited | $1,508.00 | 52 | 12% | Dec 13, 2025 | |
| Freya Okafor | Pro | Active | $36.00 | 3 | 21% | May 2, 2023 | |
| 7 | 3 | 4 distinct | $3,429.00 | 59 | 49.1% | Oct 10, 2023 – May 19, 2026 | |
| 11 | 4 | 4 distinct | $11,373.00 | 60 | 46.4% | Jan 25, 2023 – Aug 2, 2026 | |
| 16 | 4 | 4 distinct | $12,673.00 | 57 | 60.3% | Mar 3, 2023 – Aug 5, 2026 | |
| 12 | 4 | 3 distinct | $10,389.00 | 58 | 47.1% | Jan 3, 2023 – Oct 23, 2026 | |
| 10 | 4 | 4 distinct | $4,458.00 | 28 | 39.6% | Jun 6, 2023 – Oct 14, 2026 | |
| 10 | 4 | 4 distinct | $6,252.00 | 53 | 60.5% | Nov 6, 2023 – Jun 18, 2026 | |
| 5 | 3 | 3 distinct | $0.00 | 53 | 25.8% | May 15, 2023 – Jun 21, 2026 | |
| $53,633 | max 64 |
import { DataGrid, createDataGridColumns } from "@/components/ui/data-grid";
import { makePeople, PLAN_OPTIONS, STATUS_OPTIONS, type Person,} from "@/lib/data-grid-demo-data";
const col = createDataGridColumns<Person>();
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0,});
const columns = col.columns([ // Grouping by a name makes no sense, so keep it out of the "Group" menu. col.text("name", { header: "Name", groupable: false }), col.text("team", { header: "Team" }), col.enum("plan", { header: "Plan", options: PLAN_OPTIONS }), // Your own render for the aggregate: the default would be the unique count on its own. col.enum("status", { header: "Status", options: STATUS_OPTIONS, aggregatedCell: ({ getValue }) => ( <span className="text-muted-foreground">{String(getValue())} distinct</span> ), }), // A currency column sums by default. The footer is the grand total over every filtered row. col.currency("mrr", { header: "MRR", currency: "USD", footer: ({ column }) => usd.format(column.getAggregationValue<number>() ?? 0), }), // Any registered aggregation by name; the group row and the footer both use it. col.number("seats", { header: "Seats", aggregationFn: "max", footer: ({ column }) => `max ${column.getAggregationValue<number>() ?? 0}`, }), col.percent("usage", { header: "Usage" }), col.date("joinedAt", { header: "Joined" }),]);
const data = makePeople(80, 29);
/** Grouped by team to start, one group open. Group rows show the count and each column's aggregate. */export default function DataGridGrouping() { return ( <DataGrid key="grouping" data={data} columns={columns} getRowId={(row) => row.id} initialState={{ grouping: ["team"], expanded: { "team:Platform": true } }} /> );}Use it
Section titled “Use it”Three ways in, all equivalent:
- The Group button in the toolbar lists every groupable column with a checkbox, plus Clear grouping once something is grouped.
- A column’s menu has Group by Team (or Ungroup when it already is).
- The grouping bar on the left of the toolbar shows the grouped columns as chips, in order; the × on a chip ungroups that column.
To start grouped, set initialState.grouping. Group rows have the id columnId:value (nested
groups join with >), so you can open one from initialState.expanded too:
<DataGrid data={data} columns={columns} initialState={{ grouping: ["team"], expanded: { "team:Platform": true } }}/>Choosing an aggregation
Section titled “Choosing an aggregation”Every column kind has a default:
| Kind | Default aggregationFn |
Shown in the group row as |
|---|---|---|
number, currency |
sum |
The total, formatted like the column. |
percent |
mean |
The average, as a percentage. |
date, datetime |
extent |
The earliest and latest, 12 Jan 2024 – 3 Mar 2026. |
boolean |
count |
How many rows. |
enum, text, link, tags |
uniqueCount |
How many distinct values. |
custom, col.display |
none | Nothing, until you set one. |
Pick another by name. The names the grid registers are sum, min, max, extent, mean,
median, unique, uniqueCount, count, first and last, plus "auto" (sum for numbers,
extent for dates):
col.number("seats", { header: "Seats", aggregationFn: "max" })Or write your own. An aggregation is an object with an aggregate function that receives the
group’s rows and a getValue for the column; constructAggregationFn only types it:
import { constructAggregationFn, type DataGridFeatures } from "@/components/ui/data-grid"
const weightedUsage = constructAggregationFn<DataGridFeatures, Person, number, number>({ aggregate: ({ rows, getValue }) => { const seats = rows.reduce((n, r) => n + r.original.seats, 0) return seats ? rows.reduce((n, r) => n + getValue(r) * r.original.seats, 0) / seats : 0 },})
col.percent("usage", { header: "Usage", aggregationFn: weightedUsage })Rendering the aggregate
Section titled “Rendering the aggregate”The default aggregatedCell formats the value the way the column formats its cells (currency as
currency, dates as dates), renders an extent as min – max, a count as a plain number and a
unique list as the values or N values, and names the aggregation in the cell’s tooltip. Give the
column its own aggregatedCell to render it differently; getValue() is the aggregate:
col.enum("status", { header: "Status", options: STATUS_OPTIONS, aggregatedCell: ({ getValue }) => <span className="text-muted-foreground">{String(getValue())} distinct</span>,})Footers and grand totals
Section titled “Footers and grand totals”A column with a footer gets a footer row, pinned to the bottom of the grid.
column.getAggregationValue() runs the column’s aggregation over every row that passes the current
filters, on every page, grouped or not, so it is the grand total rather than the page total:
col.currency("mrr", { header: "MRR", currency: "USD", footer: ({ column }) => usd.format(column.getAggregationValue<number>() ?? 0),})The result’s type is whatever the aggregation returns (number for sum, a [min, max] pair for
extent), so pass it as the generic. The footer renders only when some column defines one.
How it behaves
Section titled “How it behaves”- Group rows. The grouped column’s cell shows a chevron, the formatted group value and the number
of leaf rows in parentheses. Click it, or press Enter on a focused cell of the row, to
open or close the group. Group rows are tinted and bold and carry
data-grouped. - Several columns nest:
grouping: ["team", "plan"]groups by team, then by plan inside each team. The chips show the order; the Group menu toggles membership. - Grouped columns move to the front (
groupedColumnMode: "reorder"), in grouping order, and lose their drag handle while grouped. Passtable={{ groupedColumnMode: false }}to leave them where they are, or"remove"to hide them (the value is in the group row anyway). - Sorting runs after grouping: group rows are ordered by the sorted column’s value (the group value, or the aggregate for other columns) and the rows inside each group are sorted too.
- Filtering runs before grouping, so counts and aggregates describe the filtered rows.
- Paging counts group rows; the members of an open group render on the same page.
- Selection. Ticking a group row selects every row in it; some selected shows indeterminate.
- Editing is off on group rows.
- Export skips group rows and writes the leaf rows in their grouped, sorted order.
- Tags columns are not groupable by default: a row has several tags, and a group per array would be meaningless.
- Persistence.
groupingis part of the view the grid remembers and of saved views. URL state can own it too. - Server-side.
server={{ grouping: true }}stops local grouping; you deliver the group rows. Aggregates computed bygetAggregationValue()still run over the rows the grid has, which is the current page. See Server-side data.
Options
Section titled “Options”Column options:
| Option | Type | Default | What it does |
|---|---|---|---|
aggregationFn |
name, definition or array | by kind | How the column aggregates in a group row and in getAggregationValue(). Defaults are in the table above. |
aggregatedCell |
render function | built in | Renders the aggregate in a group row; ctx.getValue() is the aggregate. |
footer |
string or render function | Adds the footer row; use ctx.column.getAggregationValue() for a total. |
|
groupable |
boolean |
true |
false keeps the column out of the Group menu and its header menu. tags columns default to false. |
enableGrouping |
boolean |
true |
TanStack’s switch; groupable: false is the one the menus read, so prefer it. |
getGroupingValue |
(row) => unknown |
the cell value | Group by something derived, such as the month of a date. |
maxAggregationDepth |
number |
0 |
For tree data: 1 aggregates over direct children instead of root rows. |
Grid options:
| Option | Type | Default | What it does |
|---|---|---|---|
grouping |
boolean |
true |
false removes the Group menu, the chips and the header items. |
initialState.grouping |
string[] |
[] |
Column ids to group by, in order. |
table.groupedColumnMode |
false | "reorder" | "remove" |
"reorder" |
Where grouped columns go. |
server.grouping |
boolean |
false |
true makes grouping the server’s job. |
See also
Section titled “See also”- Expanding and tree data: the expanding behind group rows.
- Columns: kinds and their defaults.
- Export: what a grouped grid exports.