Grouped rows with aggregates
Row grouping with per-group totals is usually sold as an enterprise grid feature. Here it's a plain array reduce plus two props you already know: expandableRowsto drill into a group's line items, and a column footer for the grand total. The detail panel is a plain list, not a table nested inside a table.
Orders grouped by region
Expand a region to see its individual orders. The Total column sums per-group and grand-totals in the footer.
import React, { useState } from 'react';
import DataTable, { type ExpanderComponentProps, type TableColumn } from 'react-data-table-component';
interface Order {
id: number;
region: string;
customer: string;
amount: number;
}
interface RegionGroup {
region: string;
count: number;
total: number;
orders: Order[];
}
const ORDERS: Order[] = [
{ id: 1, region: 'West', customer: 'Acme Corp', amount: 4200 },
{ id: 2, region: 'West', customer: 'Blue Harbor Inc', amount: 1850 },
{ id: 3, region: 'West', customer: 'Candle Systems', amount: 3100 },
{ id: 4, region: 'East', customer: 'Dynamo Energy', amount: 2600 },
{ id: 5, region: 'East', customer: 'Evergreen Foods', amount: 5400 },
{ id: 6, region: 'Central', customer: 'Frontier Retail', amount: 990 },
{ id: 7, region: 'Central', customer: 'Granite Works', amount: 2200 },
{ id: 8, region: 'Central', customer: 'Harbor Logistics', amount: 1475 },
{ id: 9, region: 'Central', customer: 'Ironclad Freight', amount: 3050 },
];
// Group flat rows into per-region summary rows — a plain reduce, no groupBy API required.
const groups: RegionGroup[] = Object.values(
ORDERS.reduce<Record<string, RegionGroup>>((acc, order) => {
const g = (acc[order.region] ??= { region: order.region, count: 0, total: 0, orders: [] });
g.count += 1;
g.total += order.amount;
g.orders.push(order);
return acc;
}, {}),
);
const columns: TableColumn<RegionGroup>[] = [
{ id: 'region', name: 'Region', selector: r => r.region, sortable: true, style: { fontWeight: 600 } },
{ id: 'count', name: 'Orders', selector: r => r.count, sortable: true, right: true, width: '110px' },
{
id: 'total',
name: 'Total',
selector: r => r.total,
sortable: true,
right: true,
width: '130px',
format: r => `$${r.total.toLocaleString()}`,
footer: (rows: RegionGroup[]) => `$${rows.reduce((sum, r) => sum + r.total, 0).toLocaleString()}`,
},
];
function RegionOrders({ data }: ExpanderComponentProps<RegionGroup>) {
return (
<div style={{ padding: '10px 32px' }} className="bg-gray-50">
<div className="space-y-1.5">
{data.orders.map(order => (
<div
key={order.id}
className="flex items-center justify-between text-sm bg-white border border-gray-200 rounded-md px-3 py-2"
>
<span className="text-gray-700">{order.customer}</span>
<span className="font-medium text-gray-900">${order.amount.toLocaleString()}</span>
</div>
))}
</div>
</div>
);
}
export default function RowGroupingDemo() {
const [allExpanded, setAllExpanded] = useState(false);
return (
<div className="space-y-2">
<div className="flex justify-end">
<button
onClick={() => setAllExpanded(v => !v)}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-200 text-gray-600 hover:border-gray-300"
>
{allExpanded ? 'Collapse all' : 'Expand all'}
</button>
</div>
<div className="rounded-xl border border-gray-200 overflow-hidden">
<DataTable
columns={columns}
data={groups}
keyField="region"
expandableRows
expandableRowsComponent={RegionOrders}
expandableRowExpanded={() => allExpanded}
highlightOnHover
dense
/>
</div>
</div>
);
}This demo uses Tailwind utility classes for layout — swap in your own CSS when copying into your project.
Group the flat data yourself
No groupBy prop to learn — reduce your flat rows into per-group summary objects before they ever reach the table:
const groups: RegionGroup[] = Object.values(
orders.reduce<Record<string, RegionGroup>>((acc, order) => {
const g = (acc[order.region] ??= { region: order.region, count: 0, total: 0, orders: [] });
g.count += 1;
g.total += order.amount;
g.orders.push(order);
return acc;
}, {}),
);Drill into a group without nesting another table
Each summary row already carries its own orders, so the expander component needs no extra data fetching. It doesn't need to be a DataTable either — a simple line-item list reads more clearly than a table nested inside a table:
function RegionOrders({ data }: ExpanderComponentProps<RegionGroup>) {
return (
<div style={{ padding: '10px 32px' }}>
{data.orders.map(order => (
<div key={order.id} style={{ display: 'flex', justifyContent: 'space-between', background: '#fff', border: '1px solid #e5e7eb', borderRadius: 6, padding: '8px 12px', marginBottom: 6 }}>
<span>{order.customer}</span>
<strong>${order.amount.toLocaleString()}</strong>
</div>
))}
</div>
);
}
<DataTable
columns={columns}
data={groups}
keyField="region"
expandableRows
expandableRowsComponent={RegionOrders}
/>Expand all / collapse all
Same pattern as any other expandable table — one boolean in state, flipped by the button above the demo, expands or collapses every region at once:
const [allExpanded, setAllExpanded] = useState(false);
<DataTable
columns={columns}
data={groups}
keyField="region"
expandableRows
expandableRowsComponent={RegionOrders}
expandableRowExpanded={() => allExpanded}
/>Per-group and grand totals with footer
The same footer function that produces a table-wide total also works on grouped data — it receives whatever rows are currently visible:
{
id: 'total',
name: 'Total',
selector: r => r.total,
format: r => `$${r.total.toLocaleString()}`,
footer: (rows: RegionGroup[]) => `$${rows.reduce((sum, r) => sum + r.total, 0).toLocaleString()}`,
}See Footer for more on static labels vs. computed aggregates.