Features guide
Everything below is available in the free @kanunilabs/pivotgrid-react
and @kanunilabs/pivotgrid packages unless a section is explicitly marked
Enterprise. Each feature is
driven by props or per-field configuration — no imperative setup required.
Most features are configured on the PivotField objects you pass to
initialFields (or the controlled fields prop), or on the layout,
stateStoring, theme and toolbar props of BasePivotGrid.
Aggregation
Every measure (a field in the data area) declares a summaryType:
{ id: 'revenue', dataField: 'revenue', caption: 'Revenue', dataType: 'number',
area: 'data', summaryType: 'sum' }
Supported: sum, count, avg, min, max, distinct (distinct count) and
custom. The built-in summaries run in the Web Worker, off the main thread.
Custom summaries
For bespoke rollups, provide calculateCustomSummary (start → calculate →
finalize), the same three-phase model used by classic pivot engines. It runs
once per cell, subtotals and grand totals included, over that cell's own rows:
{ id: 'priceRange', dataField: 'price', caption: 'Price Range', dataType: 'number',
area: 'data', summaryType: 'custom',
calculateCustomSummary: (o) => {
if (o.summaryProcess === 'start') { o.lo = Infinity; o.hi = -Infinity; }
if (o.summaryProcess === 'calculate') {
o.lo = Math.min(o.lo, o.value);
o.hi = Math.max(o.hi, o.value);
}
if (o.summaryProcess === 'finalize') o.totalValue = o.hi - o.lo;
} }
Rows arrive in source order, and anything you store on the options object stays there between phases. The worker groups the rows; your function runs on the main thread, because a function cannot be sent to a worker.
To reuse one summary across fields, register it and refer to it by name. The function receives the cell's values:
import { PivotEngineRegistry } from '@kanunilabs/pivotgrid-core';
const registry = new PivotEngineRegistry();
registry.registerAggregator('median', (values) => {
const s = [...values].sort((a, b) => a - b);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
});
// pass `registry` to the grid, then:
{ id: 'medianPrice', dataField: 'price', caption: 'Median Price', dataType: 'number',
area: 'data', summaryType: 'custom', calculateCustomSummary: 'median' }
A name that is not registered stops the grid with an error that names it, instead of quietly showing a sum.
Summary display modes
Transform an aggregated value after it is computed with summaryDisplayMode:
{ id: 'revenue', dataField: 'revenue', caption: '% of Total', dataType: 'number',
area: 'data', summaryType: 'sum', summaryDisplayMode: 'percentOfTotal' }
| Mode | Shows each cell as… |
|---|---|
absolute | the raw aggregated value (default) |
percentOfTotal | % of the grand total |
percentOfRowTotal | % of its row total |
percentOfColumnTotal | % of its column total |
percentOfParentRowTotal | % of its parent row group, in the same column |
percentOfParentColumnTotal | % of its parent column group, in the same row |
runningTotal | a cumulative running total |
difference | the difference from the previous value |
percentDifference | the % difference from the previous value |
The percent modes are computed on a 0–100 scale. A field without a format
of its own shows them as the locale's percent with at most two decimals —
37.51% in English, %37,51 in Turkish — on screen, in CSV and PDF, and in
Excel (number format 0.00"%"). A format on the field replaces this; the
value it receives is still on the 0–100 scale.
% of the parent total
With nested row fields, percentOfParentRowTotal answers "what share of its
group is this?" rather than "what share of everything?":
// Rows: Region > Country. Each country shows its share of its region,
// each region its share of the grand total.
{ id: 'share', dataField: 'revenue', caption: 'Share of parent', dataType: 'number',
area: 'data', summaryType: 'sum', summaryDisplayMode: 'percentOfParentRowTotal' }
| Row | Revenue | Share of parent |
|---|---|---|
| EMEA | 100 | 50% (of the 200 grand total) |
| EMEA › Germany | 60 | 60% (of EMEA) |
| EMEA › France | 40 | 40% (of EMEA) |
| APAC | 100 | 50% |
| Grand total | 200 | 100% |
- The parent is the immediate parent group on that axis. A top-level group's parent is the grand total, and the grand total itself shows 100%.
- Each column is divided separately: Germany in 2025 is a share of EMEA in 2025.
- A collapsed group shows its own share of its parent; its children are not computed until it is opened.
- Hiding subtotal rows (
showRowTotals: false) changes nothing: the parent's total is still what the share is taken of. percentOfParentColumnTotalis the same rule across nested column fields, within each row.- A parent that totals 0 shows 0.
Grouping
Group date and number fields with groupInterval:
{ id: 'orderDate', dataField: 'orderDate', caption: 'Month', dataType: 'date',
area: 'column', groupInterval: 'month' }
Date intervals: year, quarter, month, week, day. quarter, month
and day gather the same period of every year into one member (every January
together); nest them under a year field to keep years apart.
week is the ISO 8601 week: weeks start on Monday, and week 1 is the week
holding the year's first Thursday. Each member is labelled with its ISO
week-year and number, so years never mix and the members sort in calendar
order:
{ id: 'orderWeek', dataField: 'orderDate', caption: 'Week', dataType: 'date',
area: 'row', groupInterval: 'week' }
// 2026-12-28 → "2026-W53", 2027-01-01 → "2026-W53", 2027-01-04 → "2027-W01"
Like the other intervals, a date is read as the local calendar date it falls on.
Numbers take a bucket width as a number: groupInterval: 100 groups into
0–99, 100–199, and so on. The strings numeric and custom have no effect, and
the grid reports them when the fields are set.
Sorting
Sort a field's own values, or sort a row/column dimension by a measure:
// Sort Regions by descending Revenue rather than alphabetically.
{ id: 'region', dataField: 'region', caption: 'Region', dataType: 'string',
area: 'row', sortOrder: 'desc', sortBySummaryField: 'revenue' }
sortBySummaryField targets a data field by id. For an order the built-ins
cannot express, supply calculateCustomSort — a comparator that also receives
the two cells being compared.
Top-N
Keep only the leading values of a row or column dimension. topN is applied
after the sort, so it means "top by whatever you just sorted by":
// The ten highest-revenue regions, and nothing else.
{ id: 'region', dataField: 'region', caption: 'Region', dataType: 'string',
area: 'row', sortOrder: 'desc', sortBySummaryField: 'revenue', topN: 10 }
It applies per level: a topN on Region and a different one on Category keeps
the top regions, and within each, the top categories.
Grand totals still cover the whole dataset. A "top 10 regions" view totals all regions, not the ten on screen, so the visible rows will not add up to the total — unless you ask for the remainder:
{ ...region, topN: 10, topNShowOthers: true, topNOthersCaption: 'Other regions' }
topNShowOthers folds everything below the cut into one extra row (or column)
carrying its own figures.
One honest limit:
avganddistinctmeasures are left EMPTY in that row. The remainder is combined from cells that are already aggregated, which is exact forsum,count,minandmax— but an average needs the row counts behind each value and a distinct count needs the values themselves, and neither survives aggregation. Rather than print a plausible wrong number in a total row, those cells stay blank and the grid reports which measures they were through youronErrorhandler.All three of these were declared and read by nothing until 2026-08-27, and this page carried a worked example of the "Others" row before it existed.
Value and label filters
Two more ways to choose which members of a row or column field stay — the ones a spreadsheet pivot calls value and label filters. By the member's total:
// Regions that sold over a million, and nothing else.
{ id: 'region', dataField: 'region', caption: 'Region', dataType: 'string', area: 'row',
valueFilter: { dataField: 'revenue', operator: '>', value: 1_000_000 } }
operator is >, >=, <, <=, =, !=, between or notBetween; the
two range operators take a second bound in value2 (inclusive, either order).
dataField is the id of a field in the data area, and the test reads its
summary — before any summaryDisplayMode, so a percent-of-total view still
filters on the amount.
By the member's caption:
// Cities whose name starts with "an" — Ankara and Antalya.
{ ...city, labelFilter: { operator: 'startsWith', value: 'an' } }
operator is equals, notEquals, contains, notContains, startsWith or
endsWith. The match ignores case unless you set caseSensitive: true, and it
lowercases in the grid's locale, so in Turkish "iz" matches "İzmir". A blank
member reads as empty text.
They run per level, after the sort and before topN, so the two combine
the way the sentence reads: valueFilter over a million plus topN: 3 is "the
top three of those over a million". As with Top-N, the totals keep counting
every member — the subtotals and the grand total do not shrink to the rows
on screen. A parent whose members are all filtered out stays as one row with
its own total. To leave rows out of the figures themselves, filter the source
data instead, with layout.prefilter.
Both filters are kept in the saved state, so a reader's choice survives a
reload, and a change through controller.updateField recomputes straight away.
A filter that cannot be honoured — a dataField not in the data area, an
unknown operator, a missing value2 — leaves the level unfiltered and is
reported through your onError handler, rather than quietly showing nothing.
Readers set them on screen too. The menu of a row or column field (right-click
the field, or Shift+F10 on it) has Filter by condition…, and so does the
button at the bottom of its member list. The dialog has one line for the
caption and one for a total, with the data field to test. None removes that
line. A field with a condition shows the same filled filter icon as one with
members unticked. The dialog keeps a caseSensitive: true your code set; it
does not offer the switch itself.
Filtering
Header filters are built in — click a row/column field header to include or exclude values. You can also seed filters declaratively:
{ id: 'country', dataField: 'country', caption: 'Country', dataType: 'string',
area: 'filter', filterType: 'include', filterValues: new Set(['USA', 'Germany']) }
Fully keyboard-driven: the filter popup never needs the mouse — start typing to search, use ↑/↓ to move through the values, Space to toggle a checkbox, Backspace to jump straight back to the search box (deleting as you go), and Enter to apply.
The visual Prefilter builder (pre-aggregation, multi-condition) is an Enterprise feature — see Enterprise & licensing.
Quick search
search puts a search box in the footer. Typing in it keeps only the row
members whose caption contains the text, with the members above them, so a
customer or an invoice can be found in a long pivot without building a filter:
<BasePivotGrid gridId="sales" data={data} initialFields={fields} search />
The match ignores case and accents — "sao" finds "São Paulo", and in any locale "istanbul" finds "İSTANBUL". A member that matches keeps all its own children. A match deeper down keeps its parents, even ones that are collapsed: the parent stays collapsed as the reader left it, and expanding it shows only the matching children. The search waits for a pause in the typing; Enter searches at once and Escape clears the box.
The search is a view on top of the reader's own filters, not one of them: it
never changes a field's filter, is not saved with the layout or in a report,
and clearing the box gives the previous view back. Like the label filter, it
only decides which rows are shown — subtotals and the grand total keep
counting all the data. It matches the caption as the header draws it, in the
grid's locale: a date grouped by month is found by its name ("Jan", or "Oca" in
Turkish), not by its number. Text that a cellTemplate draws instead is not
searched. Expand all with a search on expands every member at that level,
the hidden ones too, so clearing the search shows them expanded as well.
From code, with or without the box (grid.setSearchText in plain JavaScript):
gridRef.current?.setSearchText('acme'); // '' clears it
Conditional formatting
Style cells by value with expression rules. Each rule provides a formula
(value, cell) => boolean and a style applied when it returns true:
{ id: 'revenue', dataField: 'revenue', caption: 'Revenue', dataType: 'number',
area: 'data', summaryType: 'sum',
conditionalFormatting: [
{ type: 'expression', formula: (v) => v < 0, style: { color: '#dc2626', fontWeight: 600 } },
{ type: 'expression', formula: (v) => v > 100000, style: { backgroundColor: '#dcfce7' } },
] }
Rules are evaluated top-to-bottom and merged, so later rules win on conflicting
properties. style is a standard React CSSProperties object.
The other three rule types
PivotConditionRule.type offers four kinds, and since @kanunilabs/pivotgrid-core
1.3.0 all four render. The three scale-based ones take their configuration in the same
style object:
conditionalFormatting: [
// A colour scale across the measure's range.
{ type: 'colorScale', style: { colors: ['#f8fafc', '#2563eb'] } },
// An in-cell bar, painted UNDER the value so the number stays readable.
{ type: 'dataBar', style: { color: '#3b82f6', negativeColor: '#ef4444' } },
// A glyph before the value. Cut points are in the measure's own units;
// without them the range is split evenly between the icons.
{ type: 'iconSet', style: { icons: ['arrow-down', 'minus', 'arrow-up'], thresholds: [0, 100] } },
]
Two things are worth knowing:
Totals are excluded from the range. A grand total sits an order of magnitude above the cells beside it; letting it set the top of a colour scale would push every real value into the first stop — correct, and useless.
Only colorScale survives an export. A spreadsheet cell in our writers
carries a fill colour, so a colour scale exports as that colour. A bar and an
icon have no cell-level equivalent, so an exported file keeps the value without
them — and setting one of those rules says so through your onError handler
when the fields are set, once per message.
Note also that the export applies the first matching rule where the screen merges all of them, so a cell with two matching rules can look different in a downloaded file.
Totals & layout
Grand totals, subtotals and the row-header shape are controlled by the layout
prop (PivotLayoutState):
<BasePivotGrid
gridId="sales"
data={data}
initialFields={fields}
layout={{
showRowGrandTotals: true, // the grand total row
showColumnGrandTotals: true, // the grand total column
showRowTotals: true, // per-level subtotals on the row axis
showColumnTotals: true, // …and on the column axis
totalsPosition: 'bottom', // 'top' | 'bottom', both axes
rowHeaderLayout: 'tree', // 'tabular' (a column per field) | 'tree' (nested)
hideEmptySummaryCells: true, // drop empty rows/columns
dataFieldPosition: 'columns', // where measure captions live
}}
/>
The coarse form
There is a second, coarser spelling of the same switches — one option per axis pair instead of one per axis:
layout={{
showGrandTotals: 'both', // → showRowGrandTotals + showColumnGrandTotals
showSubTotals: 'both', // → showRowTotals + showColumnTotals
totalsDisplayMode: 'top', // → totalsPosition
}}
These are translated at the controller boundary, so either spelling works. If you pass both, the granular one wins — it is the more specific request.
Column subtotals
Expanding a column group adds a subtotal column carrying that group's own
figure, beside its children — the column-axis twin of the subtotal rows. It
follows totalsPosition, so 'top' puts it before the children.
layout={{ showColumnTotals: true, totalsPosition: 'bottom' }}
It is on by default, like showRowTotals. Set showColumnTotals: false
for the column set you had before this landed.
New in 1.3.0. Before it the option was accepted and reported as unimplemented through
onError. Two things are worth knowing about how it works: the figure is not a second aggregation pass — the engine already totals every visible node, so the subtotal column addresses the group's own value — and it appears in exports too, because the exporters derive their columns from the same list the screen does.All four coarse options were declared and read by nothing until 2026-08-27, and this page used two of them in the example above.
Column widths
Row-header columns start at 150px and data columns at 95px. Long member names
or large totals need more, and defaultColumnWidths sets the starting width
for every column of a kind, however many there turn out to be:
<BasePivotGrid
gridId="sales"
data={data}
initialFields={fields}
defaultColumnWidths={{ rowHeader: 280, data: 120 }}
/>
rowHeader applies to each row-header column — one per row field in the
tabular layout, the single column in the tree layout — and data to each data
column. Either can be left out. The frozen row-header block is still kept
narrow enough to leave room for the data, so in a small container a wide
rowHeader is painted narrower than asked.
A width the user drags beats the default, and is saved with the layout when
stateStoring is on. To set one column from code, use the ref:
const grid = useRef<PivotGridRef>(null);
// Keys: `row-<i>` for row-header column i (0 is the outermost row field),
// `leaf-<i>` for data column i, counted left to right with totals included.
grid.current?.setColumnWidths({
...grid.current.getState().columnWidths,
'row-0': 320,
});
setColumnWidths replaces the whole map, so spread the current one to change a
single column; a column left out goes back to defaultColumnWidths. In the
JavaScript edition the option is the same key on createPivotGrid, and the
method is grid.controller.setColumnWidths.
Import
The footer's Import menu ingests external data without code. It reads
CSV / TSV / TXT, Excel (.xlsx, .xls, .xlsm, .xlsb), XML and
JSON, plus clipboard paste (delimited text) and drag-and-drop onto
the grid. Column types are inferred with locale-aware parsing (EU/US decimals,
dates, booleans, leading-zero IDs stay strings). Large files stream with a
progress bar and can be cancelled.
Receive imported rows via onImportData, and optionally show a preview +
column-mapping step first:
<BasePivotGrid
gridId="sales"
data={data}
initialFields={fields}
previewImport // show a preview/mapping step before applying
onImportData={(rows) => setData(rows)}
/>
Export
The footer's Export menu produces CSV, JSON (list / tree / full session
snapshot), XML, Print and Copy to clipboard (tab-separated) in the Community
build. Styled Excel (WYSIWYG Pivot View or flat List View) and PDF export are
Enterprise features — in Community they appear in the menu with an "Enterprise" badge,
and become active when you render EnterprisePivotGrid. Large exports switch to a
streaming writer, and CSV output is sanitized against formula injection.
Cancellation and progress are plumbed through exportData's signal and
onProgress, but the built-in download menu passes neither — you reach them only
through your own PivotGridRef. Row assembly yields between chunks; the three
heaviest calls do not. See Styled Excel & PDF export for
what changes at 100,000 rows and what the file will not match on screen.
Hook onExporting to observe or cancel an export:
<BasePivotGrid
gridId="sales" data={data} initialFields={fields}
onExporting={(e) => { if (!userAllowed) e.cancel = true; }}
/>
Saved views & state persistence
The footer's report menu lets users save, load and set a default named view
(fields + layout). Persist the whole grid state across reloads with
stateStoring:
<BasePivotGrid
gridId="sales" data={data} initialFields={fields}
stateStoring={{ enabled: true, type: 'localStorage', storageKey: 'sales-pivot' }}
/>
Use type: 'custom' with onSave/onLoad to persist to your own backend.
onLoad may return a Promise — an IndexedDB read, a request: the grid starts
on its default layout and switches when it lands, and saves nothing until then,
so the default never overwrites the state being loaded. To open on the stored
layout with no visible switch, read it before mounting and return it as is.
The same state in one piece, from code — for a tab you unmount and rebuild, or a view kept on your server:
const saved = ref.current.getPersistedState(); // versioned plain JSON
// … later, on a new grid:
ref.current.applyPersistedState(saved); // false for another version
It holds the fields and their areas and order, sorting, filters, summaries and
calculated fields, totals and layout options, the chart type, open members and
column widths. Fields your app no longer defines are dropped on restore — the
calculated fields the reader made stay — and fields added since join as you
define them. A state with another version is refused without an error and the
grid keeps its layout. The vanilla grid has the same pair on api.controller.
Two more things travel with it. Parts registered with
controller.registerReportStatePart(key, { get, apply }) — the Enterprise chart
panel keeps its chart this way, and your own chart or side panel can too (call
controller.notifyStatePartChanged() when it changes, so stateStoring saves
it). And the scroll, only when asked for: getPersistedState({ include: ['scroll'] }) or stateStoring={{ …, include: ['scroll'] }}; a restored
scroll is applied once the pivot is drawn.
Theming & striping
The grid is themed entirely with CSS variables. Pass a theme object, or toggle
zebra striping with striped:
<BasePivotGrid
gridId="sales" data={data} initialFields={fields}
striped
theme={{ primary: '#0284c7', surfaceHeader: '#e0f2fe', border: '#bae6fd' }}
/>
surfaceHeader and textHeader colour the panels — the filter area, the field
areas, the footer, dialog headers — and, unless you say otherwise, the column
headers too. For a dark header band over light panels, give the band its own
pair:
theme={{ surfaceHeader: '#f1f5f9', headerBackground: '#1e293b', headerText: '#f8fafc' }}
Try the presets live in the Theming playground.
Colour schemes
Both packages ship 36 ready-made colour schemes, light and dark, each one a small stylesheet next to the grid's own. Browse them, or make your own from any of them, in the Theme Builder — it previews the pivot table and exports the stylesheet. In a shadcn/ui app the grid can take your shadcn theme instead, dark mode included — see Tailwind CSS and shadcn/ui. Import the one you want and name it by its id:
import '@kanunilabs/pivotgrid-react/styles.css';
import '@kanunilabs/pivotgrid-react/themes/graphite.css';
<BasePivotGrid gridId="sales" data={data} initialFields={fields} theme={{ id: 'graphite' }} />
The id is all the grid needs: it marks the grid root with data-theme, and
every popup it opens (field menus, the filter list, dialogs, the field chooser,
the drill-down) carries the same mark, so the scheme reaches all of them.
themes/all.css holds every scheme at once, for an app that lets its users
choose. The plain JavaScript package has the same files:
import '@kanunilabs/pivotgrid/styles.css';
import '@kanunilabs/pivotgrid/themes/graphite.css';
createPivotGrid(host, { data, initialFields: fields, theme: { id: 'graphite' } });
A scheme and your own colours combine: anything you set in the theme object
wins over the scheme, so a scheme can be the starting point for a brand colour:
<BasePivotGrid … theme={{ id: 'graphite', primary: '#f59e0b' }} />
Without an id, or with an id no imported stylesheet knows, the grid keeps its
default look. A chart drawn with ChartFactory beside the grid follows the
scheme when you wrap it in <PivotThemeProvider theme={{ id: 'graphite' }}> and
pass palette="theme", which derives the series colours from the scheme's
accent.
Density
density sets the row rhythm. It changes no colours, so it combines with any
theme or scheme:
<BasePivotGrid gridId="sales" data={data} initialFields={fields} density="compact" />
density | Rows | Also |
|---|---|---|
'compact' | 24px | Type one size smaller; for screens that need the most rows. |
'normal' (default) | 28px | The grid as it has always looked. |
'comfortable' | 36px | Larger targets on the toolbar buttons. |
Header rows follow the same height. Menus, the filter list and dialogs keep
their size in every density. Changing the prop repaints in place, without
re-running the pivot engine. In the plain JavaScript package, pass density in
the config and switch it later with grid.setDensity('comfortable').
Localization & RTL
Twelve locales ship in the box; ar flips the layout to RTL automatically. See
the Quick start and the
Localization playground.
Performance
Built-in aggregation runs in a Web Worker over columnar storage with a trie-based grouper, so the UI stays responsive on a million rows — try the Big Data Performance playground (100 → 1,000,000 rows). The worker is compiled into the package and started from a Blob URL, so there is nothing to configure in any bundler:
<BasePivotGrid data={data} initialFields={fields} />
A strict Content-Security-Policy is the one case that still needs attention — see Installation.
Too many columns
A field with a different value on almost every row — an invoice number, a
timestamp — dragged onto the columns would make tens of thousands of them, and
drawing their headers would freeze the page. The grid draws the first
maxColumns column members (1,000 by default) and says above the table how
many of how many it shows, suggesting to move the field to the rows or filter
it:
<BasePivotGrid data={data} initialFields={fields} maxColumns={500} />
Only the columns are cut, not the data: the row totals and the grand total
still count every row, and the rows are all there. maxColumns={0} (or
Infinity) turns the cap off. getEngineResult().columnsTruncated reports
{ shown, total } when it applies. Expand all expands the columns past the
cap as well.
The cap is for the screen only: no export is cut. An export of the current
view keeps the rows and the expansion on screen but has every column, so its
columns add up to the row totals; an export with expandAll is computed afresh
and keeps every column too. The file can therefore be much wider than the grid.
Live updates
A dashboard that receives rows as they change does not have to hand the grid a
new data array every time. applyTransaction adds, updates and removes rows
without a rebuild: only the rows in the transaction are encoded and sent to the
worker, which regroups them and recomputes the groups they touched.
const grid = useRef<PivotGridRef>(null);
<BasePivotGrid ref={grid} gridId="desk" data={trades} rowKey="id" initialFields={fields} />
// on every message from the feed
grid.current?.applyTransaction({
update: [{ id: 'T-1042', desk: 'Rates', pnl: 18250 }],
add: [{ id: 'T-1043', desk: 'FX', pnl: -400 }],
remove: ['T-0999'],
});
In JavaScript the call is on the grid itself, grid.applyTransaction({ … }),
with rowKey in the createPivotGrid options.
- Rows are matched by
rowKey— a field name, or a function returning a string or number. Without it rows are matched by object identity: update with the very object that is in your data, changed in place, and remove by passing rows rather than keys. - An update for a key the grid does not have is ignored, a remove wins over an update of the same row, and added rows go to the end — the same rules as the DataGrid's transactions.
- It returns what it applied,
{ added, updated, removed }, andgetRawData()returns the new rows at once; the recomputed pivot follows a moment later (theengineCompletedevent, or the next render). - The result is the pivot a rebuild from the new rows would show: the same groups, totals, filters, sorting and custom summaries. The totals a change touches are summed again from their rows, not adjusted by the difference, so decimal totals do not drift over thousands of updates.
- Re-rendering with the same
dataarray keeps the changes. A new array replaces every row, as before. - A transaction that arrives while the grid is still building, or while a field is being moved, is applied as soon as that finishes — in order, without starting the build again.
Measured on 2026-10-03 over a million rows (three grouping fields, two measures; a transaction of 10 updates, 5 removes and 10 adds, in Node 24): about 17 ms of worker time per transaction against 230–290 ms to rebuild there, and about 35 ms on the main thread against 160 ms to encode every row again. Most of that main-thread time is finding the changed rows by key, which is one pass over all rows.
Two things to know. The first transaction on a grid prepares the engine for
the ones after it with one regrouping of the stored data (about 0.3 s at a
million rows), without encoding anything again. And a value that no longer
occurs in any row stays in the engine's dictionary until the next full build —
a new data array, setData or Refresh.
Feeds that tick many times a second
Every applyTransaction finds its rows by key in one pass over all rows and
sends the worker its own message. A feed that calls it a hundred times a second
pays that a hundred times for one picture. Queue the ticks instead:
<BasePivotGrid ref={grid} rowKey="id" asyncTransactionWait={50} /* … */ />
// on every message from the feed
grid.current?.applyTransactionAsync({ update: [tick] });
Queued transactions are merged and applied together once asyncTransactionWait
milliseconds (default 50) have passed since the first of them. The optional
second argument is a callback that runs when its batch has been applied.
flushAsyncTransactions() applies the queue at once, and
controller.getPendingAsyncTransactionCount() says how many transactions wait.
- The merged batch leaves the rows that applying each transaction in order
would. An add followed by an update is one add of the latest row; an add
followed by a remove leaves nothing; the last update of a row wins; a remove
drops an earlier update. Inside one transaction, "remove
7, add7" is a replacement. - The order of your calls is kept. A synchronous
applyTransaction,setDataor a newdataarray applies the queue first. - Destroying the grid drops the queue without applying it.
In JavaScript the calls are on the grid, grid.applyTransactionAsync(…) and
grid.flushAsyncTransactions(), with asyncTransactionWait in the
createPivotGrid options. Measured on 2026-10-08 over a million rows in Node 24:
finding the rows for 100 single-row ticks took about 1.7 s one by one and about
24 ms merged, and the worker received one message instead of a hundred.
Toolbar customization
Show or hide individual footer controls with the toolbar prop:
<BasePivotGrid
gridId="sales" data={data} initialFields={fields}
toolbar={{ showExportButton: true, showImportFile: false, showCalculatedFields: false }}
/>
Looking for calculated fields, charts or drill-down? Those are in the Enterprise package. For every prop and type, see the API Reference.
Missing a feature? Tell us
The grid grows out of real-world requests — if something you need is missing, awkward, or plain broken, we genuinely want to hear it. Write to [email protected] or use the contact form; license holders can also open a support ticket. Every message is read and answered.