Documentation

Events

The grid publishes what happens on a typed event bus. Props like onRowClick reach whoever renders the grid; events reach anything else — a detail pane, a router, an audit trail, a layout you persist.

Subscribing

onReady hands you the controller. Everything hangs off controller.events:

<DataGrid
  dataSource={rows}
  rowKey="id"
  columns={columns}
  onReady={(controller) => {
    const off = controller.events.on('cellValueChanged', (e) => {
      console.log(e.columnId, e.previousValue, '→', e.value, `(${e.source})`);
    });
    // `on` returns its own unsubscribe.
    return off;
  }}
/>

Payloads are typed from the event name — GridEventMap is the single source, so e above is a CellValueChangedEvent with no cast. Every payload type is exported from @kanunilabs/datagrid-core if you need to name it:

import type { CellValueChangedEvent, PaginationChangedEvent } from '@kanunilabs/datagrid-core';

Three patterns worth knowing first

Coarse and granular events coexist. columnStateChanged fires for every column change and carries the whole state; columnResized / columnMoved / columnPinned / columnVisible each name one gesture. Subscribe to the coarse one to persist a layout, to a granular one to react to a specific action. The granular events are additive — nothing stopped firing when they arrived.

Gestures come in brackets. Undo, redo, cut, copy, paste and fill each publish a …Start and a …End around the per-cell events they produce, so a listener syncing a backing store can batch 500 changes instead of issuing 500 writes. The close always arrives — including when the gesture did nothing, and including when it threw.

Events describe the grid's view of the data, not the DOM. No payload carries a DOM or React event: the core runs and is tested without a browser, and a React synthetic event is pooled — invalid by the time an async listener reads it. If you need preventDefault, use the prop; props run first.


Data & lifecycle

EventPayloadFires when
contentReadyrows, totalCount, visibleCountThe pipeline produced a result — after any sort, filter, search, grouping or data change.
firstDataRenderedrowCountOnce, on the first non-empty view. Not the same as onReady, which fires before any data has been through the pipeline.
dataUpdatedtotalCountThe source rows were replaced.
rowsChangedadded, updated, removedAn applyTransaction landed.
asyncTransactionsFlushedcountA batch of applyTransactionAsync calls was applied as one. count is how many CALLS the batch absorbed, not how many rows moved — it fires even when the batch cancelled itself out, because the caller asked to be told when its transactions were dealt with.
dataErrorerrorA remote block failed to load. The grid keeps the rows it has.
busyChangedbusy, progress, phase, cancellableLong work started or finished. progress is a real percentage only where one exists — otherwise null, meaning "indeterminate".
summariesChangedvaluesThe footer totals landed. Above ~100k rows the summary pass streams in slices after contentReady (the rows paint first, the totals follow moments later); this event is how the footer — or your own listener — learns the final numbers. Below that size summaries are computed inside the pipeline and this event does not fire.
stateUpdatedscope, stateAny interaction state moved. One subscription instead of six; scope is 'data', 'view' or 'page'.
gridPreDestroyedstatedispose() was called. Emitted before teardown, so getRows() still answers inside your handler — this is where you save a layout.

Sorting, filtering, grouping

EventPayload
sortChangedsort
filterChangedfilter, searchText
groupChangedgroupBy, levels

Selection & focus

EventPayload
selectionChangedselectedKeys, selectedCount
focusChangedfocus
focusedRowChangedrowKey, previousRowKey, row?

focusedRowChanged fires only when the row changes, so moving left and right along one row does not wake row-level listeners.

Interaction

EventPayload
cellClicked · cellDoubleClicked · cellContextMenurowKey, rowIndex, columnId, value, row?, node
rowClicked · rowDoubleClickedrowKey, rowIndex, row?, node

value is resolved the same way the renderer resolves it, so a valueGetter column reports what the user is actually looking at. On a group header row and value are undefined rather than invented.

rowIndex is the position in the current view — after filter, sort, grouping and paging.

Editing

EventPayload
cellValueChangedrowKey, columnId, value, previousValue, row?, source
cellEditingStartedrowKey, columnId, value
cellEditingStoppedrowKey, columnId, value, cancelled
rowEditingStartedrowKey, isNew, row?
rowEditingStoppedrowKey, isNew, cancelled, row?

cellValueChanged fires the moment the grid's own view of a value changes — not when it is persisted. Those are different instants in every mode but cell, and a chart or a dirty-form indicator wants the first one. onSave and onRowUpdated remain the persistence hooks.

source says which gesture produced it: 'edit', 'cut', 'fill', 'paste', 'undo' or 'redo'. Without it you cannot tell your own history navigation from user input, or batch a paste.

Two things that are easy to assume and wrong:

  • Committing an untouched editor emits cellEditingStopped with no cellValueChanged. The two events are not derivable from one another.
  • A refused save — validation or the server said no — emits nothing. The editor is still on screen with the error in it; the user has not moved on.

Undo / redo

EventPayload
undoStarted · redoStartedlabel
undoEnded · redoEndedperformed, label

The bracket wraps the replayed values, so the order you see is undoStarted → each cellValueChanged with source: 'undo'undoEnded. Asking to undo an empty history still opens and closes the bracket, with performed: false.

Clipboard & fill

EventPayload
cutStart · copyStart · pasteStart · fillStartcellCount
cutEnd · copyEnd · pasteEnd · fillEndcellCount, performed

cut is defined but nothing triggers it yet — the grid has no cut command today. The contract is in place for when one lands.

Columns

EventPayload
columnStateChangedcolumns
columnResizedcolumnId, width, previousWidth, finished
columnMovedcolumnId, fromIndex, toIndex
columnPinnedcolumnId, pinned, previous
columnVisiblecolumnId, visible
columnGroupOpenedband, open

columnResized.finished is what makes a drag usable: every pointer move reports false, the release reports true once. Persist on the close, or you write a layout per pixel.

Bulk operations — applyColumnStates, resetColumnState, sizeColumnsToFit, a columns prop swap — publish only columnStateChanged. Replaying a restored layout as dozens of synthetic gestures would tell you the user did things they never did.

columnGroupOpened reports a header band folding or unfolding, with band as the caption path (outermost first). Folding is not hiding: a column tucked away with its band keeps visible: true, because that flag belongs to the column chooser and the user did not touch it. Which columns survive a fold is decided per column by bandShow.

Pagination, viewport, rendering

EventPayload
paginationChangedpage, pageSize, pageCount, fromResultChange
viewportChangedwindow
bodyScrollscrollTop, scrollLeft, firstRow, lastRow
bodyScrollEndsame payload, once the scrolling stops
gridSizeChangedwidth, height, previousWidth, previousHeight
scrollRequestedscrollTop?, scrollLeft?
cellsFlashedrowKeys, columnIds, duration
cellsRefreshedrevision

pageCount is in the pagination payload because it moves without anyone touching the pager — a filter that narrows the result re-paginates it. fromResultChange separates "the user paged" from "the ground moved".

viewportChanged fires only when the quantized window actually moves, not per scrolled pixel. bodyScroll is the other half of that pair: it fires whenever the offsets change, including a scroll of a few pixels that moves no rows — which is what a panel synchronized beside the grid needs. bodyScrollEnd follows once the offsets have been still for a moment, so work you only want to do when the user has settled (fetching previews, recording a reading position) does not run sixty times on the way there.

gridSizeChanged reports the drawing area, not the window: a grid that got wider shows the same rows, and a layout listening for the size still needs to hear about it.

Row drag

EventPayload
rowDragEnter · rowDragMove · rowDragLeaverowKey, row?, overKey, position, fromGridId
rowDragEndthe same, plus dropped

rowDragEnd fires for every drag that ends, including one abandoned with Escape or dropped outside a target — dropped is what separates "the user moved a row" from "the user thought about it". overKey is null when the pointer is past the last row, which is where "move to the end" lands.

rowDragMove is emitted only when the drop target actually changes, not on every dragover tick — the browser fires those continuously while the pointer sits still.

fromGridId differs from this grid's id when the row came from another grid sharing a rowDrag.group.

Overlays

EventPayload
overlayChangedoverlay, manual

The grid decides on its own: busy shows the loading overlay, an empty result shows the no-rows message. The API is for what the grid cannot know — controller.showLoadingOverlay() while you fetch the data you are about to hand over, showNoRowsOverlay() to say "nothing matches" for a reason of your own, and hideOverlay() to hand the decision back.

hideOverlay() is not "show nothing": it releases the override, so an empty grid goes back to saying it is empty. controller.getOverlay() answers with what should be on screen right now, override resolved.

cellsFlashed and cellsRefreshed are intents the adapter paints — the core has no DOM. You will normally call controller.flashCells(...) and controller.refreshCells() rather than listen to them.


Deliberately absent

Two things you might look for and not find, with the reason:

columnHeaderClicked — a header click is a sort gesture today, and sortChanged already reports the outcome. A separate event would need its own answer for what happens when the click was on the menu, the resize handle or the filter funnel.

A "filtered but unsorted" iteration hook (AG Grid's forEachNodeAfterFilter) — this engine's filter and sort are one indivisible pass, which is why BusyPhase calls it computing. There is no moment when that array exists; producing it would mean running the filter a second time and selling the cost as an API. forEachNode, forEachNodeAfterFilterAndSort and forEachDisplayedNode cover the three stages that are real.

A worked example

Persisting a layout, without saving on every pixel of a resize drag:

onReady={(controller) => {
  const offs = [
    // The coarse event covers resize, move, pin, visibility and bulk restores.
    controller.events.on('columnStateChanged', ({ columns }) => {
      localStorage.setItem('grid-columns', JSON.stringify(columns));
    }),
    // …but during a drag, wait for the close.
    controller.events.on('columnResized', ({ finished }) => {
      if (!finished) return; // mid-drag: the coarse event already fired, ignore it
      // persist here if you want resize handled separately
    }),
    controller.events.on('gridPreDestroyed', ({ state }) => {
      localStorage.setItem('grid-view', JSON.stringify(state));
    }),
  ];
  return () => offs.forEach((off) => off());
}}