ShadTable
DiscordGitHub

ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

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
CategoryCustomerStatusAmount
$1,428.49
Ava ThompsonPaid$249.99
Liam ChenPaid$79.50
Sofia PatelPending$1,099.00
$255.95
Noah GarciaPaid$45.00
Mia JohnsonRefunded$120.75
Ethan KimPaid$32.20
Isabella RossiPending$58.00
$400.39
Lucas MartinPaid$310.40
Amelia NovakPaid$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.

src/components/grouped/data-table.tsx
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.

src/components/grouped/columns.tsx
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 null
return <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.

src/components/grouped/columns.tsx
{
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.

src/components/grouped/columns.tsx
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.

src/components/grouped/data-table.tsx
const [expanded, setExpanded] = useState<ExpandedState>(true)
// same state shape and handler as src/components/tree/data-table.tsx