Building pivoted React DataGrids with generated columns
By raduΒ·
Pivot tables start as a reporting request and quickly become a UI architecture problem.
You need to group rows, aggregate values, create columns from data values, keep those columns typed, and still leave room to customize headers, widths, formatting, totals, and server-side loading. Infinite Table's pivoting feature keeps that work inside the
DataSource pipeline so your React grid can render a report-style layout without hardcoding every possible result column.The docs cover this in the pivoting guide. This article walks through the key idea β define pivoting at the data level, then render the generated columns in
<InfiniteTable /> β and maps it to real product scenarios.Pivoting belongs in the DataSource#
In Infinite Table, the
DataSource owns grouping, pivoting, and aggregation because those features reshape the data before the grid renders it.const groupBy = [{ field: 'department' }, { field: 'country' }];
const pivotBy = [{ field: 'team' }];
<DataSource<Developer>
groupBy={groupBy}
pivotBy={pivotBy}
aggregationReducers={aggregationReducers}
>
{({ pivotColumns, pivotColumnGroups }) => {
return (
<InfiniteTable<Developer>
columns={columns}
pivotColumns={pivotColumns}
pivotColumnGroups={pivotColumnGroups}
/>
);
}}
</DataSource>; COPY
The
children render prop of the DataSource is the handoff for a pivot DataGrid. The DataSource looks at pivotBy and aggregationReducers, groups and aggregates the data, then gives the table the generated pivotColumns and pivotColumnGroups.Without that split, you would scan the dataset yourself, discover every pivot value, construct matching columns, wire column groups, and keep the result in sync as grouping or pivoting changes.
Map a business question to pivot config#
A useful way to design a pivot view is to write the question first, then fill in three lists:
- Rows (
groupBy) β what you compare down the page: region, department, product line - Columns (
pivotBy) β what becomes generated headers: month, channel, plan tier - Values (
aggregationReducers) β what you measure: sum of revenue, avg salary, ticket count
If the column headers depend on values in the data β countries that appear this quarter, warehouses that currently hold stock, channels that received tickets β you want generated pivot columns, not a static column list.
Scenario 1: People analytics β language Γ stack, pivoted by country#
Imagine a developers analytics grid. Rows are grouped by
preferredLanguage and stack, then pivoted by country and whether a developer can design. Instead of one salary column, the grid shows aggregated salary for each country/design combination.That is the classic βcompare compensation across geography while keeping role structure on the rowsβ report β the same shape as headcount or average salary by department and location.
The demo below groups developers, pivots the salary aggregation into generated columns, and starts with groups collapsed so the cross-tab is easy to scan.
Expand a group to see aggregated salary values distributed across generated pivot columns. Full walkthrough: pivoting overview.
View Mode
Fork Forkimport { InfiniteTable, DataSource, GroupRowsState, } from '@infinite-table/infinite-react'; import type { InfiniteTableColumnAggregator, InfiniteTablePropColumns, DataSourcePropAggregationReducers, DataSourceGroupBy, DataSourcePivotBy, } from '@infinite-table/infinite-react'; import * as React from 'react'; type Developer = { id: number; firstName: string; lastName: string; country: string; city: string; currency: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; hobby: string; salary: number; age: number; }; const dataSource = () => { return fetch('https://data.infinite-table.com' + '/developers100') .then((r) => r.json()) .then((data: Developer[]) => data) .then( (data) => new Promise<Developer[]>((resolve) => { setTimeout(() => resolve(data), 1000); }), ); }; const avgReducer: InfiniteTableColumnAggregator<Developer, any> = { initialValue: 0, field: 'salary', reducer: (acc, sum) => acc + sum, done: (sum, arr) => (arr.length ? sum / arr.length : 0), }; const reducers: DataSourcePropAggregationReducers<Developer> = { salary: avgReducer, }; const columns: InfiniteTablePropColumns<Developer> = { id: { field: 'id' }, firstName: { field: 'firstName' }, preferredLanguage: { field: 'preferredLanguage' }, stack: { field: 'stack' }, country: { field: 'country' }, canDesign: { field: 'canDesign' }, hobby: { field: 'hobby' }, city: { field: 'city' }, age: { field: 'age' }, salary: { field: 'salary', type: 'number' }, currency: { field: 'currency' }, }; const groupRowsState = new GroupRowsState({ expandedRows: [], collapsedRows: true, }); export default function GroupByExample() { const groupBy: DataSourceGroupBy<Developer>[] = React.useMemo( () => [ { field: 'preferredLanguage', }, { field: 'stack' }, ], [], ); const pivotBy: DataSourcePivotBy<Developer>[] = React.useMemo( () => [ { field: 'country' }, { field: 'canDesign', }, ], [], ); return ( <> <DataSource<Developer> primaryKey="id" data={dataSource} groupBy={groupBy} pivotBy={pivotBy} aggregationReducers={reducers} defaultGroupRowsState={groupRowsState} > {({ pivotColumns, pivotColumnGroups }) => { return ( <InfiniteTable<Developer> debugId="pivoting-example" columns={columns} pivotColumns={pivotColumns} pivotColumnGroups={pivotColumnGroups} columnDefaultWidth={200} pivotTotalColumnPosition="end" /> ); }} </DataSource> </> ); }
In product terms, the same shape answers questions like:
- Average salary by technology stack, broken out by country
- Headcount by department, broken out by office
- Utilization by team, broken out by project phase
Scenario 2: Support ops β volume by priority Γ channel#
Support and customer-success teams often need a matrix, not a flat ticket list:
- Rows: priority (
critical,high,normal) or product area - Columns: channel (
email,chat,phone,portal) - Values: ticket count, average time-to-first-response, reopen rate
const groupBy = [{ field: 'priority' }, { field: 'productArea' }];
const pivotBy = [{ field: 'channel' }];
const aggregationReducers = {
ticketCount: {
name: 'Tickets',
initialValue: 0,
reducer: (acc) => acc + 1,
},
avgFirstResponseMins: {
name: 'Avg first response (min)',
field: 'firstResponseMins',
initialValue: 0,
reducer: (acc, value) => acc + value,
done: (sum, arr) => (arr.length ? sum / arr.length : 0),
},
}; COPY
As new channels appear in the feed, Infinite Table generates matching pivot columns. You do not ship a release every time marketing adds βWhatsAppβ as a support channel.
The same pattern works for sales pipeline stages by region, or incidents by severity and service.
Scenario 3: Commerce β revenue by product line Γ region (with totals)#
Finance and merchandising reports usually need both the matrix and the summary columns:
- Rows: product category β SKU family
- Columns: region β sales channel
- Values: sum of revenue, sum of units
That is where
pivotTotalColumnPosition and pivotGrandTotalColumnPosition matter:- Pivot totals summarize inside a pivot group (for example, all channels under EMEA)
- Grand totals summarize across the whole pivot surface for each aggregation
Pivot total columns only appear when you pivot by two or more fields (
pivotBy.length > 1). With a single pivot field, enabling pivotTotalColumnPosition has no effect β those totals would duplicate the values already shown. The example below uses two pivot levels (stack and canDesign) so both kinds of totals are visible.const pivotBy = [{ field: 'stack' }, { field: 'canDesign' }];
<InfiniteTable<Developer>
columns={columns}
pivotColumns={pivotColumns}
pivotColumnGroups={pivotColumnGroups}
pivotTotalColumnPosition="end"
pivotGrandTotalColumnPosition="start"
COPY
Pivot total columns sit at the end of each stack group. Grand-total columns are placed at the start of the grid.
View Mode
Fork Forkimport { InfiniteTable, DataSource, DataSourceGroupBy, DataSourcePivotBy, InfiniteTableColumnAggregator, DataSourcePropAggregationReducers, } from '@infinite-table/infinite-react'; import type { InfiniteTablePropColumns } from '@infinite-table/infinite-react'; import * as React from 'react'; type Developer = { id: number; firstName: string; lastName: string; country: string; city: string; currency: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; hobby: string; salary: number; age: number; }; const dataSource = () => { return fetch('https://data.infinite-table.com' + '/developers1k') .then((r) => r.json()) .then((data: Developer[]) => data); }; const columns: InfiniteTablePropColumns<Developer> = { id: { field: 'id', defaultWidth: 80 }, preferredLanguage: { field: 'preferredLanguage' }, stack: { field: 'stack' }, }; const defaultGroupBy: DataSourceGroupBy<Developer>[] = [ { field: 'country', }, { field: 'city', }, ]; const defaultPivotBy: DataSourcePivotBy<Developer>[] = [ { field: 'stack', }, { field: 'canDesign', columnGroup: ({ columnGroup }) => { return { ...columnGroup, header: columnGroup.pivotGroupKey === 'yes' ? 'Designer' : 'Non-designer', }; }, }, ]; const avgReducer: InfiniteTableColumnAggregator<Developer, any> = { initialValue: 0, reducer: (acc, sum) => acc + sum, done: (sum, arr) => Math.round(arr.length ? sum / arr.length : 0), }; const aggregations: DataSourcePropAggregationReducers<Developer> = { salary: { ...avgReducer, name: 'Salary (avg)', field: 'salary', }, age: { ...avgReducer, name: 'Age (avg)', field: 'age', }, }; export default function ColumnValueGetterExample() { return ( <> <DataSource<Developer> primaryKey="id" defaultGroupBy={defaultGroupBy} defaultPivotBy={defaultPivotBy} aggregationReducers={aggregations} data={dataSource} > {({ pivotColumns, pivotColumnGroups }) => { return ( <InfiniteTable<Developer> debugId="pivot-grand-total-column-position-example" groupRenderStrategy="single-column" columns={columns} columnDefaultWidth={200} pivotColumns={pivotColumns} pivotColumnGroups={pivotColumnGroups} pivotTotalColumnPosition="end" pivotGrandTotalColumnPosition="start" /> ); }} </DataSource> </> ); }
For a commerce grid, that reads as: revenue for each region/channel cell, a subtotal per region, and a grand total for the selected filters β spreadsheet expectations, without building spreadsheet UI yourself.
Scenario 4: Inventory β warehouse Γ status, readable headers#
Generated columns still need to feel like part of your app. Infinite Table gives you a few customization layers.
Inherit formatting from source columns#
If an aggregation reducer is bound to a field that already has a column, the generated pivot column inherits that column configuration β number formatting, styles, default width, and related behavior.
const columns: InfiniteTablePropColumns<Developer> = {
salary: {
field: 'salary',
type: 'number',
style: { color: 'red' },
},
};
const aggregationReducers = {
avgSalary: {
field: 'salary',
reducer: 'avg',
},
}; COPY
Aggregations bound to
salary / age pick up the original column styling. See also customizing pivot columns.View Mode
Fork Forkimport { InfiniteTable, DataSource, GroupRowsState, } from '@infinite-table/infinite-react'; import type { DataSourcePropAggregationReducers, InfiniteTableColumnAggregator, InfiniteTablePropColumns, DataSourceGroupBy, DataSourcePivotBy, } from '@infinite-table/infinite-react'; import * as React from 'react'; type Developer = { id: number; firstName: string; lastName: string; country: string; city: string; currency: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; hobby: string; salary: number; age: number; }; const dataSource = () => { return fetch('https://data.infinite-table.com' + '/developers100') .then((r) => r.json()) .then((data: Developer[]) => data); }; const avgReducer: InfiniteTableColumnAggregator<Developer, any> = { initialValue: 0, reducer: (acc, sum) => acc + sum, done: (sum, arr) => (arr.length ? sum / arr.length : 0), }; const columnAggregations: DataSourcePropAggregationReducers<Developer> = { avgSalary: { field: 'salary', name: 'Average salary', ...avgReducer, }, avgAge: { field: 'age', ...avgReducer, pivotColumn: { defaultWidth: 500, inheritFromColumn: 'firstName', }, }, }; const columns: InfiniteTablePropColumns<Developer> = { id: { field: 'id' }, firstName: { field: 'firstName', style: { fontWeight: 'bold', }, renderValue: ({ value }) => <>{value}!</>, }, preferredLanguage: { field: 'preferredLanguage' }, stack: { field: 'stack' }, country: { field: 'country' }, canDesign: { field: 'canDesign' }, hobby: { field: 'hobby' }, city: { field: 'city' }, age: { field: 'age' }, salary: { field: 'salary', type: 'number', header: 'Salary', style: { color: 'red' }, }, currency: { field: 'currency' }, }; const groupRowsState = new GroupRowsState({ expandedRows: [], collapsedRows: true, }); export default function PivotByExample() { const groupBy: DataSourceGroupBy<Developer>[] = React.useMemo( () => [ { field: 'preferredLanguage', }, { field: 'stack' }, ], [], ); const pivotBy: DataSourcePivotBy<Developer>[] = React.useMemo( () => [ { field: 'country' }, { field: 'canDesign', }, ], [], ); return ( <> <DataSource<Developer> primaryKey="id" data={dataSource} groupBy={groupBy} pivotBy={pivotBy} aggregationReducers={columnAggregations} defaultGroupRowsState={groupRowsState} > {({ pivotColumns, pivotColumnGroups }) => { return ( <InfiniteTable<Developer> debugId="pivot-column-inherit-example" columns={columns} hideEmptyGroupColumns pivotColumns={pivotColumns} pivotColumnGroups={pivotColumnGroups} columnDefaultWidth={180} /> ); }} </DataSource> </> ); }
Rename raw values into business labels#
Inventory status codes (
in_stock, reserved, quarantine) or boolean flags rarely belong in a header as-is. Use pivotBy.column as an object for every generated column at that pivot level, or as a function that receives the generated column metadata.const pivotBy: DataSourcePivotBy<Developer>[] = [
{ field: 'country' },
{
field: 'canDesign',
column: ({ column }) => {
const lastKey = column.pivotGroupKeys[column.pivotGroupKeys.length - 1];
return {
header: lastKey === 'yes' ? 'Designer' : 'Non-designer',
};
},
},
]; COPY
That callback turns raw pivot keys into labels operators recognize, while column generation stays automatic.
The
canDesign pivot values are rewritten to Designer / Non-designer headers. Try collapsing and expanding groups to see the column groups stay aligned.View Mode
Fork Forkimport { InfiniteTable, DataSource, GroupRowsState, } from '@infinite-table/infinite-react'; import type { DataSourcePropAggregationReducers, InfiniteTableColumnAggregator, InfiniteTablePropColumns, DataSourceGroupBy, DataSourcePivotBy, } from '@infinite-table/infinite-react'; import * as React from 'react'; type Developer = { id: number; firstName: string; lastName: string; country: string; city: string; currency: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; hobby: string; salary: number; age: number; }; const dataSource = () => { return fetch('https://data.infinite-table.com' + '/developers100') .then((r) => r.json()) .then((data: Developer[]) => data); }; const avgReducer: InfiniteTableColumnAggregator<Developer, any> = { initialValue: 0, field: 'salary', reducer: (acc, sum) => acc + sum, done: (sum, arr) => (arr.length ? sum / arr.length : 0), }; const columnAggregations: DataSourcePropAggregationReducers<Developer> = { salary: avgReducer, }; const columns: InfiniteTablePropColumns<Developer> = { id: { field: 'id' }, firstName: { field: 'firstName' }, preferredLanguage: { field: 'preferredLanguage' }, stack: { field: 'stack' }, country: { field: 'country' }, canDesign: { field: 'canDesign' }, hobby: { field: 'hobby' }, city: { field: 'city' }, age: { field: 'age' }, salary: { field: 'salary', type: 'number' }, currency: { field: 'currency' }, }; const groupRowsState = new GroupRowsState({ expandedRows: [], collapsedRows: true, }); export default function PivotByExample() { const groupBy: DataSourceGroupBy<Developer>[] = React.useMemo( () => [ { field: 'preferredLanguage', }, { field: 'stack' }, ], [], ); const pivotBy: DataSourcePivotBy<Developer>[] = React.useMemo( () => [ { field: 'country' }, { field: 'canDesign', column: ({ column: pivotCol }) => { const lastKey = pivotCol.pivotGroupKeys[pivotCol.pivotGroupKeys.length - 1]; return { header: lastKey === 'yes' ? 'π Designer' : 'π» Non-designer', }; }, }, ], [], ); return ( <> <DataSource<Developer> primaryKey="id" data={dataSource} groupBy={groupBy} pivotBy={pivotBy} aggregationReducers={columnAggregations} defaultGroupRowsState={groupRowsState} > {({ pivotColumns, pivotColumnGroups }) => { return ( <InfiniteTable<Developer> debugId="pivoting-customize-column-example" columns={columns} hideEmptyGroupColumns pivotColumns={pivotColumns} pivotColumnGroups={pivotColumnGroups} columnDefaultWidth={180} /> ); }} </DataSource> </> ); }
A warehouse dashboard might look like:
const groupBy = [{ field: 'skuFamily' }, { field: 'sku' }];
const pivotBy = [
{ field: 'warehouse' },
{
field: 'status',
column: ({ column }) => {
const status =
column.pivotGroupKeys[column.pivotGroupKeys.length - 1];
const labels: Record<string, string> = {
in_stock: 'Available',
reserved: 'Reserved',
quarantine: 'Quarantine',
};
return {
header: labels[status] ?? String(status),
defaultWidth: 120,
};
},
},
];
const aggregationReducers = {
units: {
name: 'Units',
field: 'quantity',
initialValue: 0,
reducer: (acc, value) => acc + value,
},
}; COPY
New warehouses in the dataset become new column groups without a frontend schema change.
Scenario 5: SaaS metrics β plans and regions at scale#
Product analytics often outgrows client-side reshaping: millions of subscription events, weekly cohorts, plan Γ region matrices. For that case, keep the same Infinite Table UI model and move the pivot computation to the server.
Enable
lazyLoad and provide a function for data that returns already-pivoted groups. The grid still generates pivotColumns / pivotColumnGroups from pivotBy and aggregationReducers, but leaf rows are not loaded β pivoting works on aggregated group payloads.const groupBy = [{ field: 'country' }, { field: 'stack' }];
const pivotBy = [
{ field: 'preferredLanguage' },
{ field: 'canDesign' },
];
const aggregationReducers = {
salary: { name: 'Salary (avg)', field: 'salary', reducer: 'avg' },
age: { name: 'Age (avg)', field: 'age', reducer: 'avg' },
};
const dataSource = ({ groupBy, pivotBy, groupKeys, aggregationReducers }) => {
// Fetch a Promise that resolves to { data, totalCount, pivot, ... }
// See the pivoting docs for the full response shape.
};
<DataSource lazyLoad data={dataSource} groupBy={groupBy} pivotBy={pivotBy} />; COPY
Typical SaaS mappings:
- MRR by segment and plan β
groupBy:segmentβaccountTier;pivotBy:planβbillingInterval; aggregations:sum(mrr),count(accounts) - Activation by acquisition channel β
groupBy:weekβcohort;pivotBy:channel; aggregation:avg(activationRate) - Churn by region and product β
groupBy:region;pivotBy:productβplan; aggregation:sum(churnedMrr)
Grouping and pivot aggregations are loaded remotely. Expand countries to fetch nested groups with pivot values already computed on the server.
View Mode
Fork Forkimport { InfiniteTable, DataSource, GroupRowsState, } from '@infinite-table/infinite-react'; import type { InfiniteTablePropColumns, InfiniteTablePropColumnPinning, DataSourceData, DataSourcePropAggregationReducers, DataSourceGroupBy, DataSourcePivotBy, } from '@infinite-table/infinite-react'; import * as React from 'react'; type Developer = { id: number; firstName: string; lastName: string; country: string; city: string; currency: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; hobby: string; salary: number; age: number; }; const DATA_SOURCE_SIZE = '10k'; const dataSource: DataSourceData<Developer> = ({ pivotBy, aggregationReducers, groupBy, groupKeys = [], }) => { const args = [ pivotBy ? 'pivotBy=' + JSON.stringify(pivotBy.map((p) => ({ field: p.field }))) : null, `groupKeys=${JSON.stringify(groupKeys)}`, groupBy ? 'groupBy=' + JSON.stringify(groupBy.map((p) => ({ field: p.field }))) : null, aggregationReducers ? 'reducers=' + JSON.stringify( Object.keys(aggregationReducers).map((key) => ({ field: aggregationReducers[key].field, id: key, name: aggregationReducers[key].reducer, })), ) : null, ] .filter(Boolean) .join('&'); return fetch( 'https://data.infinite-table.com' + `/developers${DATA_SOURCE_SIZE}-sql?` + args, ) .then((r) => r.json()) .then((data: Developer[]) => data); }; const aggregationReducers: DataSourcePropAggregationReducers<Developer> = { salary: { // the aggregation name will be used as the column header name: 'Salary (avg)', field: 'salary', reducer: 'avg', }, age: { name: 'Age (avg)', field: 'age', reducer: 'avg', }, }; const columns: InfiniteTablePropColumns<Developer> = { preferredLanguage: { field: 'preferredLanguage' }, age: { field: 'age' }, salary: { field: 'salary', type: 'number', }, canDesign: { field: 'canDesign' }, country: { field: 'country' }, firstName: { field: 'firstName' }, stack: { field: 'stack' }, id: { field: 'id' }, hobby: { field: 'hobby' }, city: { field: 'city' }, currency: { field: 'currency' }, }; // make the row labels column (id: 'labels') be pinned const defaultColumnPinning: InfiniteTablePropColumnPinning = { // make the generated group columns pinned to start 'group-by-country': 'start', 'group-by-stack': 'start', }; // make all rows collapsed by default const groupRowsState = new GroupRowsState({ expandedRows: [], collapsedRows: true, }); export default function RemotePivotExample() { const groupBy: DataSourceGroupBy<Developer>[] = React.useMemo( () => [ { field: 'country', column: { // give the group column for the country prop a custom id id: 'group-by-country', }, }, { field: 'stack', column: { // give the group column for the stack prop a custom id id: 'group-by-stack', }, }, ], [], ); const pivotBy: DataSourcePivotBy<Developer>[] = React.useMemo( () => [ { field: 'preferredLanguage' }, { field: 'canDesign', // customize the column group columnGroup: ({ columnGroup }) => { return { ...columnGroup, header: `${ columnGroup.pivotGroupKey === 'yes' ? 'Designer' : 'Non-designer' }`, }; }, // customize columns generated under this column group column: ({ column }) => ({ ...column, header: `π ${column.header}`, }), }, ], [], ); return ( <DataSource<Developer> primaryKey="id" data={dataSource} groupBy={groupBy} pivotBy={pivotBy} aggregationReducers={aggregationReducers} defaultGroupRowsState={groupRowsState} lazyLoad={true} > {({ pivotColumns, pivotColumnGroups }) => { return ( <InfiniteTable<Developer> debugId="remote-pivoting-example" defaultColumnPinning={defaultColumnPinning} columns={columns} hideEmptyGroupColumns pivotColumns={pivotColumns} pivotColumnGroups={pivotColumnGroups} columnDefaultWidth={220} /> ); }} </DataSource> ); }
For very large trees, the same guide covers lazy-load batching so groups stream in pages while the pivot column model stays consistent.
Scenario 6: Let users change the report at runtime#
Many analytics products need more than one fixed pivot. Users want to switch βgroup by department, pivot by monthβ to βgroup by region, pivot by product lineβ without a new screen.
Because
groupBy, pivotBy, and aggregationReducers are ordinary React props, you can drive them from UI state. The dynamic pivoting example shows client-side and server-side variants where those dimensions change at runtime, including custom number/currency formatting on the generated columns.That is the difference between a static export and an interactive report builder: the DataSource recomputes (or re-fetches) the pivot surface;
<InfiniteTable /> keeps rendering whatever pivotColumns it receives.When pivoting is a good fit#
Reach for pivoting when the question is not βwhich rows match this filter?β but βhow do values compare across dimensions?β
Good use cases:
- Revenue by region and product line (with region/channel totals)
- Headcount or average salary by department and location
- Support volume by priority and channel
- Inventory units by warehouse and stock status
- SaaS MRR / churn by plan and billing interval
- Incident count by severity and owning service
In each case the column structure depends on the data. Generated pivot columns are the point: you describe dimensions and aggregations, and Infinite Table creates the report surface.
Go deeper in the docs#
- Pivoting overview β core
pivotBysetup, totals, and server-side pivoting - Customizing pivot columns β inheritance, headers, widths, per-value column config
- Dynamic pivoting example β change group/pivot/aggregations from the UI
- Grouping and aggregations β reducer shapes that power pivot values