gp-grid-logo
Guides

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 > quarter has a year band, a quarter band and the leaf band.
  • A cell is getValue(viewRow, columnId). It is null when the intersection has no records and when its aggregate is null; 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

CompositionWhat the application doesEngine bundled
PrecomputedComputes or fetches the keys and the cells, builds the schema with buildPivotSchema and returns the rows from its own sourceNone
LocalWraps a flat source that holds every record in createPivotSourceThe 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, Total

buildPivotSchema(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.

PivotSchemaOptionsDefaultMeaning
measuresrequiredPivotMeasure[]: a RowGroupMeasure plus an optional column, a Partial<ColumnDefinition> over the generated column's defaults.
labelColumnnoneOverrides the label column's defaults.
rowTotalsfalseAdds the row total columns.
rowTotalsHeader"Total"Header of the row totals group.
formatKeysee below(step) => string: the header text of a tuple step.
ColumnDefaultsFixed
LabelheaderName: "", width: 200, cellDataType: "text"colId and field ("gp-pivot-label"), filterable: false
Generated and row totalheaderName: 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:

MessageCause
Invalid pivot keys: tuple 0 is emptyAn empty tuple, whose ids would collide with the row totals
Invalid pivot keys: tuple 1 has 1 steps, expected 2A 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 prefixKeys 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);
PivotConfigDefaultMeaning
rowsrequiredRowGroupDimension[] ({ field, id?, toKey? }), outermost first. Every level is a group level; the last is terminal.
pivotsrequiredRowGroupDimension[], outermost header band first. Their keys make the tuples.
measuresrequiredPivotMeasure[]: one generated column per tuple each, in this order. The aggregates are row grouping's: a built-in name or a RowGroupAggregator.
domainnoneFixed keys of a pivot dimension, by its id; see Domain.
rowTotalsfalseOne column per measure over every tuple.
rowTotalsHeader"Total"Header of the row totals group.
grandTotalnone"top" or "bottom" adds the total row.
defaultExpandedDepth0Groups of a depth below it start expanded. A value past the last non-terminal level acts as that level.
initialStatenoneA RowGroupingState, from getState() or built from group ids.
maxColumns1000The most generated columns a schema may have; see Budget.
labelColumnnoneAs in PivotSchemaOptions.
formatKeyas aboveAs 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>"):

MessageCause
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: regionA repeated dimension id; rows and pivots share one set of ids
Invalid pivot.measures.field: amountA repeated measure field
Invalid pivot.measures.aggregate: medianAn unknown aggregate name
Invalid pivot.defaultExpandedDepth: -1A negative or NaN depth
Invalid pivot.maxColumns: 0A maxColumns that is not positive
Invalid pivot.domain: regionA 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>:

MemberMeaning
loadMode"all".
writablefalse.
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:

MessageCause
pivot needs every rowThe inner source is paginated, or its response holds fewer rows than its totalRows
pivot reads unknown field quarterA dimension or measure field (source ?? field) that a columnar inner source lacks
pivot key of year is an object; add toKeyAn object key in a row or pivot dimension without toKey; names the dimension id
pivot generates 8 columns, over maxColumns 7The schema is over budget
Superseded by a later queryA 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 columnOrder of every row level
A generated column, or a row total with rowTotalsBy that column's value at each group; ties by key ascending
The label columnBy key, in that direction
NoneKeys 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 null aggregate are both null. 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) answers undefined.
  • 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 rowGrouping and a pivot source.
  • Limits beyond maxColumns are the column-group header budgets.

For the complete API contracts and advanced examples, see the upstream pivot guide.

On this page