Skip to main content

Revenue analytics workbench

One fact table, four ways to interrogate it. Group and aggregate it, pivot it into a matrix, keep each arrangement on its own sheet, and save the ones worth returning to as named views.

Loading interactive example…

What is composed here

CapabilityHow it is used
GroupingRegion, then any dimension, with sticky group headers
AggregationSum of revenue, margin, and units; average discount
PivotRegion × Channel revenue matrix on its own sheet
SheetsA data sheet and a pivot sheet, each with its own captured state
Saved viewsNamed layouts persisted to localStorage
Set filtersValue pickers on every dimension column
ExportCSV and native Excel, honouring the grouped shape

Roles, not columns

Grouping, pivoting, and aggregating are three roles a column can take. The API sets them independently, and the toolbar and Columns panel are two front ends over the same calls:

api.setRowGroupColumns(["region", "channel"]); // rows
api.setPivotColumns(["quarter"]); // columns
api.setAggregates([ // values
{ colId: "revenue", type: AggregateType.SUM },
{ colId: "discount", type: AggregateType.AVG },
]);
api.setPivotMode(true);

A column opts into the roles it can play with groupable, pivotable, and aggregatable. Turning pivot mode off restores the pre-pivot arrangement rather than clearing it.

A sheet is a captured state

Sheets are application-owned. The grid renders the tab strip and reports changes; the list and the active tab live in application state:

const [sheets, setSheets] = useState<GridSheet[]>([{ id: "data", name: "Data" }]);
const [activeSheetId, setActiveSheetId] = useState<string | null>("data");

<Grid
sheets={{
sheets,
activeSheetId,
onChange: setSheets,
onActiveSheetChange: setActiveSheetId,
}}
/>

A sheet carries a GridViewState — the same object captureViewState() returns — so a pre-built sheet is just a captured state with the pivot fields overwritten:

const dataState = api.captureViewState();

const pivotSheet: GridSheet = {
id: "by-channel",
name: "Revenue × Channel",
state: {
...dataState,
pivotMode: true,
pivotColumns: ["channel"],
rowGroupColumns: ["region", "category"],
aggregateModel: [{ colId: "revenue", type: AggregateType.SUM }],
// What turning pivot mode off on this sheet falls back to.
prePivotState: {
rowGroupColumns: dataState.rowGroupColumns,
aggregateModel: dataState.aggregateModel ?? [],
pivotColumns: dataState.pivotColumns ?? [],
},
},
};
caution

Seed sheets through application state, not updateGridOptions. The wrapper syncs options from props after onGridReady, so a sheet written imperatively is overwritten by the next prop sync — and leaving sheets undefined unmounts the tab strip.

Totals that follow the filter

The stat strip above the grid is not grid furniture — it reads the row model after filtering, recomputed whenever the filter changes:

const recomputeTotals = () => {
let revenue = 0;
api.forEachNodeAfterFilter((node) => { revenue += node.data.revenue; });
setTotals({ revenue });
};

<Grid onFilterChanged={recomputeTotals} onGridReady={(api) => recomputeTotals()} />

onFilterChanged is the canonical filter signal: it covers column filters, the quick filter, and a columnDefs update that dropped an active filter, and it fires after the row model has re-derived the view — so counts read inside the handler are already post-filter.

Saved views

A view is a named GridViewState. The grid owns the picker; the application owns the list, which is what makes views persistable anywhere:

<Grid
toolbar={{ views: true }}
savedViews={{
views,
onChange: (next) => {
setViews([...next]);
window.localStorage.setItem(KEY, JSON.stringify(next));
},
}}
/>

The views on this page are stored in your own browser, so they survive a reload and belong to nobody else.