Pivot
Build read-only pivot tables with generated columns, local aggregation or precomputed results, schema budgets, and hooks for React, Vue, and Angular grids.
A pivot shows the values of some dimensions as generated columns, with an aggregate at every intersection of a row group and a column key. It is read-only.
A pivot reaches the grid through the contracts it already has:
- Rows are a
HierarchicalRowAccess: one group row per key at every row dimension, plus an optional total row. A group row of the last row dimension is terminal: it has no leaves and no expander. There are no record rows. - Columns are ordinary column definitions. A generated column shows one measure at one leaf tuple, a path over every pivot dimension; a row total column shows one measure over every tuple. The label column leads them and holds the group label and the expander.
- Headers are column groups: one group per tuple
prefix, nested per pivot dimension, so a pivot over
year > quarterhas a year band, a quarter band and the leaf band. - A cell is
getValue(viewRow, columnId). It isnullwhen the intersection has no records and when its aggregate isnull; both render as the empty group cell.
The grid learns nothing about pivoting: no new row kind, prop or renderer parameter. The schema (the generated columns and their groups) goes in through the column API and the rows through the data source, by two commands: one frame may show the new columns over the previous rows.
Compositions
| Composition | What the application does | Engine bundled |
|---|---|---|
| Precomputed | Computes or fetches the keys and the cells, builds the schema with buildPivotSchema and returns the rows from its own source | None |
| Local | Wraps a flat source that holds every record in createPivotSource | The pivot engine |
Every composition uses the same ids and the same schema helper, so the same keys give the same columns and headers. There is no pivot over server grouping's service: a server that computes a pivot supplies it precomputed.
The schema
import { buildPivotSchema, type GroupPathStep, type PivotMeasure } from "@gp-grid/core"; // or any wrapper package
const measures: PivotMeasure[] = [
{ field: "amount", aggregate: "sum", column: { width: 100 } },
{ field: "n", source: "amount", aggregate: "count", column: { headerName: "Sales" } },
];
const y = (year: number): GroupPathStep => ["year", "n", year];
const q = (quarter: string): GroupPathStep => ["quarter", "s", quarter];
const schema = buildPivotSchema(
[[y(2024), q("Q1")], [y(2024), q("Q2")], [y(2025), q("Q1")]],
{ measures, rowTotals: true, labelColumn: { headerName: "Region / City" } },
);
// schema.columns: the label column, 3 tuples × 2 measures, 2 row totals
// schema.columnGroups: "gp-pivot-label", 2024 > (Q1, Q2), 2025 > Q1, TotalbuildPivotSchema(keys, options) takes the leaf tuples in column order. The
schema is the label column, then one column per measure for each tuple in
measure order, inside one group per tuple prefix, then with rowTotals one
column per measure under a group headed by rowTotalsHeader. Without keys it is the label
column, and the row totals when asked.
PivotSchemaOptions | Default | Meaning |
|---|---|---|
measures | required | PivotMeasure[]: a RowGroupMeasure plus an optional column, a Partial<ColumnDefinition> over the generated column's defaults. |
labelColumn | none | Overrides the label column's defaults. |
rowTotals | false | Adds the row total columns. |
rowTotalsHeader | "Total" | Header of the row totals group. |
formatKey | see below | (step) => string: the header text of a tuple step. |
| Column | Defaults | Fixed |
|---|---|---|
| Label | headerName: "", width: 200, cellDataType: "text" | colId and field ("gp-pivot-label"), filterable: false |
| Generated and row total | headerName: measure.field, width: 120, cellDataType: "number" | colId and field (the id), editable: false, filterable: false |
The default formatKey prints "(Blanks)" for a z step, a d timestamp as
its UTC date YYYY-MM-DD, and String(key) otherwise, so a schema reads the
same on the server and in every time zone. formatKey does not reach the row
totals header; rowTotalsHeader names it.
buildPivotSchema throws a RangeError when the keys cannot nest:
| Message | Cause |
|---|---|
Invalid pivot keys: tuple 0 is empty | An empty tuple, whose ids would collide with the row totals |
Invalid pivot keys: tuple 1 has 1 steps, expected 2 | A tuple of another length than the first |
Invalid pivot keys: tuple 1 level 1 is "month", expected "quarter" | A step whose dimension id differs from the first tuple's |
Invalid pivot keys: tuple 2 repeats an earlier prefix | Keys not grouped by prefix, or a repeated tuple |
Local engine
createPivotSource(source, config) wraps a flat data source that holds every
record, object or columnar, and is itself a data source. Every query reads the
inner rows, builds the row groups and the tuples, folds every measure per group
and tuple, and answers a hierarchy.
import { createClientDataSource, createPivotSource, type PivotConfig } from "@gp-grid/core"; // or any wrapper package
const config: PivotConfig = {
rows: [{ field: "region" }, { field: "city" }],
pivots: [{ field: "year" }, { field: "quarter" }],
measures: [
{ field: "amount", aggregate: "sum" },
{ field: "n", source: "amount", aggregate: "count", column: { headerName: "Sales" } },
],
rowTotals: true,
grandTotal: "top",
defaultExpandedDepth: 1,
};
const source = createPivotSource(createClientDataSource(sales), config);PivotConfig | Default | Meaning |
|---|---|---|
rows | required | RowGroupDimension[] ({ field, id?, toKey? }), outermost first. Every level is a group level; the last is terminal. |
pivots | required | RowGroupDimension[], outermost header band first. Their keys make the tuples. |
measures | required | PivotMeasure[]: one generated column per tuple each, in this order. The aggregates are row grouping's: a built-in name or a RowGroupAggregator. |
domain | none | Fixed keys of a pivot dimension, by its id; see Domain. |
rowTotals | false | One column per measure over every tuple. |
rowTotalsHeader | "Total" | Header of the row totals group. |
grandTotal | none | "top" or "bottom" adds the total row. |
defaultExpandedDepth | 0 | Groups of a depth below it start expanded. A value past the last non-terminal level acts as that level. |
initialState | none | A RowGroupingState, from getState() or built from group ids. |
maxColumns | 1000 | The most generated columns a schema may have; see Budget. |
labelColumn | none | As in PivotSchemaOptions. |
formatKey | as above | As in PivotSchemaOptions. |
Keys are row grouping's: null and
undefined share the blank key, values are keyed by type and value, a Date
by its timestamp, and an object needs toKey. A dimension's id is
id ?? field; group row ids and tuple steps use it.
Creation throws RangeError("Invalid pivot.<field>: <value>"):
| Message | Cause |
|---|---|
Invalid pivot.rows: [] | No row dimension |
Invalid pivot.pivots: [] | No pivot dimension |
Invalid pivot.measures: [] | No measure |
Invalid pivot.rows.id: city, Invalid pivot.pivots.id: region | A repeated dimension id; rows and pivots share one set of ids |
Invalid pivot.measures.field: amount | A repeated measure field |
Invalid pivot.measures.aggregate: median | An unknown aggregate name |
Invalid pivot.defaultExpandedDepth: -1 | A negative or NaN depth |
Invalid pivot.maxColumns: 0 | A maxColumns that is not positive |
Invalid pivot.domain: region | A domain key that is no pivot dimension id |
Invalid pivot.domain.year: [object Object] | A domain value that is an object other than a Date |
PivotSource<TData> is a DataSource<TData>:
| Member | Meaning |
|---|---|
loadMode | "all". |
writable | false. |
query(request) | Pivots the inner rows and resolves with the view as access and rows: []. The grid calls it. |
getSchema() | The last built schema, null before the first query. |
subscribeSchema(listener) | Calls listener(schema) when a query builds a schema with other column or group ids; returns the unsubscribe. |
getState() | The expansion by id, kept across queries. |
destroy() | Drops the listeners and releases the view the grid holds; a later query builds a new one. |
The query
The inner source gets { range: { startRow: 0, endRow: Number.MAX_SAFE_INTEGER }, filter, valueFormatters }
from the request, and no sort. The query rejects, and the grid shows its data
error, with:
| Message | Cause |
|---|---|
pivot needs every row | The inner source is paginated, or its response holds fewer rows than its totalRows |
pivot reads unknown field quarter | A dimension or measure field (source ?? field) that a columnar inner source lacks |
pivot key of year is an object; add toKey | An object key in a row or pivot dimension without toKey; names the dimension id |
pivot generates 8 columns, over maxColumns 7 | The schema is over budget |
Superseded by a later query | A later query, or destroy(), came before this one resolved |
The schema hook
A schema with other ids than the last one is passed to every listener,
synchronously, before the query that built it resolves. The application hands
it to the grid as columns and columnGroups. An unchanged schema notifies
nothing and getSchema() keeps the same object. Until the first query, show
buildPivotSchema([], config): the label column, and the row totals when
rowTotals is set.
// Core
const initial = buildPivotSchema([], config);
const core = new GridCore({ columns: initial.columns, columnGroups: initial.columnGroups, dataSource: source, rowHeight: 32 });
const stop = source.subscribeSchema((schema) => core.columns.set(schema.columns, schema.columnGroups));// React
const [schema, setSchema] = useState(() => source.getSchema() ?? buildPivotSchema([], config));
useEffect(() => {
const current = source.getSchema();
if (current) setSchema(current);
return source.subscribeSchema(setSchema);
}, [source]);
<Grid columns={schema.columns} columnGroups={schema.columnGroups} dataSource={source}
rowHeight={32} groupLabelColumn={PIVOT_LABEL_COLUMN_ID} />// Vue (script setup)
const schema = shallowRef(source.getSchema() ?? buildPivotSchema([], config));
const stop = source.subscribeSchema((next) => (schema.value = next));
onUnmounted(stop);
// <GpGrid :columns="schema.columns" :column-groups="schema.columnGroups" :data-source="source" … />// Angular (component fields)
protected readonly source = createPivotSource(createClientDataSource(sales), config);
protected readonly schema = signal(this.source.getSchema() ?? buildPivotSchema([], config));
private readonly stop = this.source.subscribeSchema((next) => this.schema.set(next));
// ngOnDestroy(): this.stop();
// <gp-grid [columns]="schema().columns" [columnGroups]="schema().columnGroups" [dataSource]="source" … />The columns and the rows still arrive by two commands, in whichever order the
wrapper applies them: one frame may show the new columns over the previous
rows. Column state follows the ids that survive a schema change (a width, a
pin, an order); an id that leaves and returns takes its definition defaults.
Do not also set rowGrouping: over a hierarchy it is rejected with
hierarchical-source.
Sort and filter
The pivot orders the row groups from the request's sort model, and the inner source gets no sort. The first entry with a direction that names one of these decides, and the others are skipped:
| Sorted column | Order of every row level |
|---|---|
A generated column, or a row total with rowTotals | By that column's value at each group; ties by key ascending |
| The label column | By key, in that direction |
| None | Keys ascending |
Values and keys of different types order as in row grouping, and a descending sort reverses the whole order. A sort on a record field has no column to name and is skipped.
The request's filter model reaches the inner source unchanged, so the records are filtered before they are pivoted. Neither the generated columns nor the label column is filterable. The grid keeps sort and filter entries only for the ids of its current columns: a schema change drops the entries of the ids that left and queries again.
Expansion and state
Expansion lives in the source by group id, as in
server grouping, and survives
every query: a refresh, a sort, a filter, a new key. Terminal groups take no
part: setExpanded ignores them and setExpanded(null, true) opens every
other group. source.getState() returns the
RowGroupingState to pass
back as initialState. core.rowGroups, onRowGroupToggled,
groupLabelColumn and groupLabelRenderer work as in row grouping.
Read-only
Every cell is read-only: the source is not writable, every row is a group or
total row, and every generated column is editable: false. There is no
getRecordById, moveRow or recordsChanged: nothing in a pivot is a record.
The pivot does not listen to its inner source. After a change to the inner
records (a mutable source's transaction, flushed), call core.refresh(): the
query pivots again and notifies a changed schema.
Limits
- One value per cell: an empty intersection and a
nullaggregate are bothnull. A renderer that needs the difference reads the application's data. - No retained state for an absent column: an id that leaves and returns takes its definition defaults.
- No incremental update: every change is a new query over every inner row.
- No leaves and no drill-through: the last row level is childless.
- No
getRecordById;rows.getRecordById(id)answersundefined. - The schema and the rows are not applied in one batch.
- No pivot over server grouping's service, no
column-group collapse, field chooser or aggregate editing, and no grid over
both
rowGroupingand a pivot source. - Limits beyond
maxColumnsare the column-group header budgets.
For the complete API contracts and advanced examples, see the upstream pivot guide.