Pivot Table
Dashboard-style analytics: pick which dimension becomes rows, which becomes columns, and how to aggregate (sum, average, or count) — the table and its totals recompute from the same flat sales data every time.
npx shadcn add https://shadcn-table-library.vercel.app/r/pivot-table.json| Region | Q1 | Q2 | Q3 | Q4 | Total |
|---|---|---|---|---|---|
| East | $55,000 | $59,400 | $52,250 | $67,100 | $233,750 |
| North | $42,000 | $45,360 | $39,900 | $51,240 | $178,500 |
| South | $31,000 | $33,480 | $29,450 | $37,820 | $131,750 |
| West | $38,000 | $41,040 | $36,100 | $46,360 | $161,500 |
| Total | $166,000 | $179,280 | $157,700 | $202,520 | $705,500 |
How it works
1.The reshape happens before the table ever sees it
pivotData() takes flat sales records plus a rowKey, colKey, and aggFn, and returns plain { rowValue, cells, total } objects — no TanStack Table types involved. The table itself has no idea what a "pivot" is; by the time useReactTable sees the data, it's already a flat list of rows like any other table in this library.
export function pivotData(data, rowKey, colKey, aggFn) {const rowValues = Array.from(new Set(data.map((d) => String(d[rowKey])))).sort()const colValues = Array.from(new Set(data.map((d) => String(d[colKey])))).sort()const rows = rowValues.map((rowValue) => {const rowRecords = data.filter((d) => String(d[rowKey]) === rowValue)const cells = {}for (const colValue of colValues) {cells[colValue] = aggregate(rowRecords.filter((d) => String(d[colKey]) === colValue), aggFn)}return { rowValue, cells, total: aggregate(rowRecords, aggFn) }})// ...}
2.Columns are generated from the data, not authored
colValues — the pivoted axis's unique values — drives a useMemo'd ColumnDef[]. Switch "Columns" from Quarter to Channel and the table goes from 4 pivoted columns to 2, entirely from what's actually in the data that render, with no static column list to keep in sync.
const columns = useMemo<ColumnDef<PivotRow>[]>(() => [{ id: 'rowValue', header: rowLabel, accessorKey: 'rowValue', /* ... */ },...colValues.map((colValue) => ({id: colValue,header: colValue,accessorFn: (row) => row.cells[colValue],cell: ({ getValue }) => formatValue(getValue(), aggFn),})),{ id: 'total', header: 'Total', accessorKey: 'total', /* ... */ },],[colValues, rowLabel, aggFn],)
3.Row and column pickers can't select the same dimension twice
Each Select's option list filters out whatever the other picker currently holds — the Rows dropdown never offers whatever Columns is already set to, and vice versa. A meaningless Region-by-Region pivot is structurally impossible to select, rather than something validated (or silently broken) after the fact.
<Select value={rowDim} onValueChange={(v) => setRowDim(v as keyof SalesRecord)}><SelectContent>{DIMENSIONS.filter((d) => d.value !== colDim).map((d) => (<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>))}</SelectContent></Select>
4.One aggregate function drives every number on the page
aggregate() is the single function called for every cell, every column subtotal, and the grand total. Sum, average, and count all reuse it, so switching "Aggregate" from Sum to Average recomputes the whole table — including the totals row — with no separate subtotal formula that could drift out of sync with the body.
export function aggregate(records: SalesRecord[], aggFn: AggregationType): number {if (aggFn === 'count') return records.lengthif (records.length === 0) return 0const total = records.reduce((sum, r) => sum + r.amount, 0)return aggFn === 'avg' ? total / records.length : total}
5.The grand-total row bypasses TanStack Table entirely
columnTotals and grandTotal are rendered directly into a <TableFooter>, not through table.getRowModel() — it's a single row that doesn't sort, filter, or paginate like the others, so keeping it outside the table's own row model means it never has to fight those features if this example grows to include them later.
<TableFooter><TableRow><TableCell>Total</TableCell>{colValues.map((colValue) => (<TableCell key={colValue}>{formatValue(columnTotals[colValue], aggFn)}</TableCell>))}<TableCell>{formatValue(grandTotal, aggFn)}</TableCell></TableRow></TableFooter>