Grouped Table
Orders grouped by category, with collapsible group headers and a live subtotal of each group's order amounts.
npx shadcn add https://shadcn-table-library.vercel.app/r/grouped-table.json| Category | Customer | Status | Amount |
|---|---|---|---|
| $1,428.49 | |||
| Ava Thompson | Paid | $249.99 | |
| Liam Chen | Paid | $79.50 | |
| Sofia Patel | Pending | $1,099.00 | |
| $255.95 | |||
| Noah Garcia | Paid | $45.00 | |
| Mia Johnson | Refunded | $120.75 | |
| Ethan Kim | Paid | $32.20 | |
| Isabella Rossi | Pending | $58.00 | |
| $400.39 | |||
| Lucas Martin | Paid | $310.40 | |
| Amelia Novak | Paid | $89.99 |
How it works
1.Groups come from a state array, not authored nesting
Unlike the Tree Table (where children arrays are hand-authored data), grouping starts from a flat list of orders. grouping: ['category'] plus getGroupedRowModel() reshapes that flat list into group-header rows with their matching orders as subRows — the same subRows shape the Tree Table uses, just computed automatically from a column's values instead of written by hand.
const [grouping, setGrouping] = useState<GroupingState>([groupBy])const table = useReactTable({data,columns,getGroupedRowModel: getGroupedRowModel(),getExpandedRowModel: getExpandedRowModel(),state: { grouping, expanded },})
2.Only one cell per group row renders the toggle
cell.getIsGrouped() is true only for the single cell whose column is the active grouping column (Category) on a group-header row — that's the one cell with an expand/collapse button and the group's value. Every other cell in that same row is either aggregated or a blank placeholder, never a duplicate of the group value.
cell: ({ row, cell, getValue }) => {if (cell.getIsGrouped()) {return (<button onClick={row.getToggleExpandedHandler()}><ChevronRight className={row.getIsExpanded() ? 'rotate-90' : ''} />{getValue()} ({row.subRows.length})</button>)}if (cell.getIsPlaceholder()) return nullreturn <span className="pl-5">{getValue()}</span>}
3.Subtotals come from aggregationFn, not manual math
The Amount column's aggregationFn: 'sum' runs automatically over every row in a group. getValue() returns a leaf order's own amount on a normal row, or the whole group's sum on a group-header row — from the exact same cell function, with no separate subtotal calculation to keep in sync.
{accessorKey: 'amount',header: 'Amount',aggregationFn: 'sum',cell: ({ cell, getValue }) => (<span className={cell.getIsAggregated() ? 'font-medium' : undefined}>{currency(getValue() as number)}</span>),}
4.Columns without an aggregationFn render blank on group rows
cell.getIsAggregated() is true for any non-grouping column on a group row, whether or not that column defines an aggregationFn — Customer and Status don't sum or average sensibly, so their cell functions explicitly return null on aggregated cells instead of showing a meaningless default value.
cell: ({ cell, getValue }) =>cell.getIsAggregated() ? null : (getValue() as string),
5.Expansion is the same mechanism as the Tree Table
Group rows collapse and expand through the exact same expanded state and row.getToggleExpandedHandler() the Tree Table uses — grouping and tree nesting are two different ways of producing the same subRows shape, so the expand/collapse code is identical between them.
const [expanded, setExpanded] = useState<ExpandedState>(true)// same state shape and handler as src/components/tree/data-table.tsx