Documentation

Features guide

Try it live:

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' }
ModeShows each cell as…
absolutethe 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
runningTotala cumulative running total
differencethe difference from the previous value
percentDifferencethe % 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' }
RowRevenueShare of parent
EMEA10050% (of the 200 grand total)
EMEA › Germany6060% (of EMEA)
EMEA › France4040% (of EMEA)
APAC10050%
Grand total200100%
  • 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.
  • percentOfParentColumnTotal is 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: avg and distinct measures are left EMPTY in that row. The remainder is combined from cells that are already aggregated, which is exact for sum, count, min and max — 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 your onError handler.

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

Try it live:

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.

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" />
densityRowsAlso
'compact'24pxType one size smaller; for screens that need the most rows.
'normal' (default)28pxThe grid as it has always looked.
'comfortable'36pxLarger 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

Try it live:

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 }, and getRawData() returns the new rows at once; the recomputed pivot follows a moment later (the engineCompleted event, 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 data array 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, add 7" is a replacement.
  • The order of your calls is kept. A synchronous applyTransaction, setData or a new data array 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.