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
| Event | Payload | Fires when |
|---|---|---|
contentReady | rows, totalCount, visibleCount | The pipeline produced a result — after any sort, filter, search, grouping or data change. |
firstDataRendered | rowCount | Once, on the first non-empty view. Not the same as onReady, which fires before any data has been through the pipeline. |
dataUpdated | totalCount | The source rows were replaced. |
rowsChanged | added, updated, removed | An applyTransaction landed. |
asyncTransactionsFlushed | count | A 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. |
dataError | error | A remote block failed to load. The grid keeps the rows it has. |
busyChanged | busy, progress, phase, cancellable | Long work started or finished. progress is a real percentage only where one exists — otherwise null, meaning "indeterminate". |
summariesChanged | values | The 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. |
stateUpdated | scope, state | Any interaction state moved. One subscription instead of six; scope is 'data', 'view' or 'page'. |
gridPreDestroyed | state | dispose() was called. Emitted before teardown, so getRows() still answers inside your handler — this is where you save a layout. |
Sorting, filtering, grouping
| Event | Payload |
|---|---|
sortChanged | sort |
filterChanged | filter, searchText |
groupChanged | groupBy, levels |
Selection & focus
| Event | Payload |
|---|---|
selectionChanged | selectedKeys, selectedCount |
focusChanged | focus |
focusedRowChanged | rowKey, previousRowKey, row? |
focusedRowChanged fires only when the row changes, so moving left and
right along one row does not wake row-level listeners.
Interaction
| Event | Payload |
|---|---|
cellClicked · cellDoubleClicked · cellContextMenu | rowKey, rowIndex, columnId, value, row?, node |
rowClicked · rowDoubleClicked | rowKey, 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
| Event | Payload |
|---|---|
cellValueChanged | rowKey, columnId, value, previousValue, row?, source |
cellEditingStarted | rowKey, columnId, value |
cellEditingStopped | rowKey, columnId, value, cancelled |
rowEditingStarted | rowKey, isNew, row? |
rowEditingStopped | rowKey, 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
cellEditingStoppedwith nocellValueChanged. 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
| Event | Payload |
|---|---|
undoStarted · redoStarted | label |
undoEnded · redoEnded | performed, 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
| Event | Payload |
|---|---|
cutStart · copyStart · pasteStart · fillStart | cellCount |
cutEnd · copyEnd · pasteEnd · fillEnd | cellCount, performed |
cutis defined but nothing triggers it yet — the grid has no cut command today. The contract is in place for when one lands.
Columns
| Event | Payload |
|---|---|
columnStateChanged | columns |
columnResized | columnId, width, previousWidth, finished |
columnMoved | columnId, fromIndex, toIndex |
columnPinned | columnId, pinned, previous |
columnVisible | columnId, visible |
columnGroupOpened | band, 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
| Event | Payload |
|---|---|
paginationChanged | page, pageSize, pageCount, fromResultChange |
viewportChanged | window |
bodyScroll | scrollTop, scrollLeft, firstRow, lastRow |
bodyScrollEnd | same payload, once the scrolling stops |
gridSizeChanged | width, height, previousWidth, previousHeight |
scrollRequested | scrollTop?, scrollLeft? |
cellsFlashed | rowKeys, columnIds, duration |
cellsRefreshed | revision |
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
| Event | Payload |
|---|---|
rowDragEnter · rowDragMove · rowDragLeave | rowKey, row?, overKey, position, fromGridId |
rowDragEnd | the 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
| Event | Payload |
|---|---|
overlayChanged | overlay, 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());
}}