Events & state
Every callback the grid fires, what it's for, and how it's shaped.
Every writable slice of the grid's API has a matching read hook or change callback, following one of three shapes.
Click, double-click, right-click, or edit a cell, drag-select, sort/filter, drag-fill, scroll, resize/pin/hide a column.
Event inspector
Interact with the grid to see events appear here.
The panel on the right pretty-prints the LATEST payload per event source, most recent first, capped at 20 entries. Interact with the grid on the left (click a cell, edit it, drag-select, sort or filter a column, drag-fill, scroll, resize, pin, or hide a column) to see each one fire.
Find the callback you need
| Need | Callback | Section |
|---|---|---|
| React to a data edit, paste, fill, or row change | onDataChange | 1. onDataChange |
| Read the active cell or a range selection | onSelectionChange | 2. onSelectionChange |
| React to a sort change | onSortChange | 3. onSortChange |
| React to a filter change | onFilterChange | 4. onFilterChange |
| Control or observe the quick-search text | searchText / onSearchTextChange | Same controlled-pair convention as 3. onSortChange |
| Intercept or veto a fill-handle drag | onFill | 5. onFill |
| React to a plain click on a cell or its row | onCellClick | 6. onCellClick |
| Veto or react to an edit starting or ending | onEditStart + onEditEnd | 10. Edit lifecycle and cell events |
| React to the active cell moving | onActiveCellChange | 10. Edit lifecycle and cell events |
React to cells a bulk paste, fill, or updateCells rejected | onValidationError | 10. Edit lifecycle and cell events |
| React to a double-click or right-click on a cell | onCellDoubleClick + onCellContextMenu | 10. Edit lifecycle and cell events |
| Fetch rows for the rendered rows on demand | onRenderedRowsChange | 7. onRenderedRowsChange |
| Save and restore a user's column layout | columnLayout + defaultColumnLayout + onColumnLayoutChange + onColumnResizing | 8. columnLayout + defaultColumnLayout + onColumnLayoutChange + onColumnResizing |
| Broadcast or receive a remote user's selection | setPresenceHighlights / useDataGridPresenceHighlights | 9. Presence read-back |
The three shapes an event follows
| Shape | Example | Controlled prop? | Restore path |
|---|---|---|---|
| Controlled pair | sortState / onSortChange (one-shot seed defaultSortState); selection / onSelectionChange (one-shot seed defaultSelection); columnLayout / onColumnLayoutChange (one-shot seed defaultColumnLayout) | Yes. Prop omitted keeps uncontrolled (store-owned) state; columnLayout is per-part, an omitted part stays uncontrolled. Gestures are first: the gesture writes the store and fires the event at once, and a re-render that feeds the fired value back applies nothing | Pass the value back in (sortState, selection, or columnLayout) |
| Data: controlled or uncontrolled opt-in | data (controlled) vs defaultData (uncontrolled) / onDataChange | Either. data makes it a controlled pair, defaultData seeds once and the store owns the array | Controlled: pass data back in. Uncontrolled: nothing to restore. The store already has it |
| Setter + read hook | useDataGridPresence().setPresenceHighlights / useDataGridPresenceHighlights(storeApi) | N/A: imperative in, hook out | Call the setter again with saved state |
1. onDataChange: the flagship shape
onDataChange?: (next: readonly TData[], change: DataChange<TData>) => void;This fires once per user gesture: a single edit, a paste, a fill, a delete-range, a row move,
or a row insert, delete, or duplicate. It passes the next full data array and a row-id-based delta
batch, change.ops. Each operation is an update, insert, delete, or move addressed by row id,
because ids survive sort and filter, and indices do not. A move operation carries from/to, the
row's snapshot positions before and after the move. Undo and redo (data-grid-history) and
CSV/xlsx export work over any consumer's row type because every mutation path normalizes to
this one shape.
This is the real shape of one operation, from types.ts:
type DataOp<TData> =
| { type: "update"; rowId: string; row: TData; prev: TData; cells?: { columnId: string; value: unknown; prev: unknown }[] }
| { type: "insert"; rowId: string; row: TData; index: number }
| { type: "delete"; rowId: string; row: TData; index: number }
| { type: "move"; rowId: string; row: TData; from: number; to: number };
type DataChange<TData> = {
ops: DataOp<TData>[];
source: "edit" | "paste" | "fill" | "delete" | "row-operation" | "import" | "history" | "rollback" | "stream" | "replace" | "app";
label?: string;
};label is a human-readable name for the entry, for example "Add row". It is set by
record(change, label) and carried through undo and redo.
A move operation of a batch (reorderRows with a list, moveRowsBy) has from as the row's
index before the whole batch and to as its index after it. Apply the operations of one change
together, for example with applyChange.
An update operation carries cells only when the change is cell-level, for example an edit, a paste,
or a fill. A whole-row update has no cells field.
The "stream" source tags a batch from updateCells. See Streaming updates.
The "replace" source tags a batch from the find & replace add-on.
The "app" source tags a programmatic change the consumer applied to its own array and
registered with the data-grid-history add-on's record(). See
Undo & redo.
The "rollback" source tags the revert that rollback(change) applies after a failed save. See Undo & redo.
Row deletion is a selection surprise to plan for: deleteRows clears the grid's entire selection
and active cell after the deletion. insertRow and duplicateRows keep the selection, offsetting
it around the inserted rows. Selection-dependent UI (a row-action toolbar, multiplayer presence)
goes blank after a delete.
onDataChange fires the same way whether you passed data or defaultData. With data
(controlled), you own the array and feed it back in. With defaultData (uncontrolled), the grid
already applied the change, and the callback is only a notification. See the Quick start for the defaultData
opt-in and the API reference for the exclusivity rule.
onDataChange fires after the grid stores next. A write action that you call inside the
handler, such as updateCells, starts from next. With data (controlled), call setData(next)
before the nested write to keep both changes. A write that an onActiveCellChange or
onSelectionChange handler makes during a grid write emits its onDataChange after the outer
one, in gesture order.
2. onSelectionChange
onSelectionChange?: (next: SelectionState, details: SelectionChangeDetails) => void;
// SelectionChangeDetails = { getValues: () => unknown[][]; getRowIds: () => string[] }The payload is the grid's selection in stable CellRef terms, rowId plus columnId, which
sorting, filtering, and reordering never change:
type SelectionState = {
anchor: CellRef | null; // the primary range's anchor (Excel's white cell)
range: CellRange | null; // { start: CellRef; end: CellRef }, either diagonal order
rangeStack: readonly CellRange[]; // earlier Ctrl/Cmd-click ranges, oldest first
rowIds: readonly string[]; // the row channel, in view order
columnIds: readonly string[]; // the column channel, in view order
};This fires on every committed selection change that alters that value: a click, a range
extend step, a row, column, or all selection, or a clear. A view rebuild that keeps every
selected record at its view position does not fire: a sort that moves nothing, a filter that
keeps the selected rows, or a data swap that moves no record. The grid applying its own
controlled selection back in does not fire either.
Drag-extend fires once per step
Dragging out a range calls this once per cell the pointer crosses, not once on release. This is intentional. Multiplayer presence and broadcast consumers want the live in-progress selection, not only the final drop. Debounce in your own handler if you only want the settled value.
The active cell is a separate store field from the selection. actions.setActiveCell and the
Alt+Arrow movement keys change only the active cell, so onSelectionChange does not fire for
them. There is no callback that observes active-cell-only changes.
Controlling the selection
onSelectionChange pairs with the selection and defaultSelection props, the same
value/defaultValue convention as sortState and filterState. Omit both for uncontrolled
(store-owned) behavior.
selection: the controlled value. Gestures are first: a click or a drag step writes the store and fires the callback at once; the grid never waits for your re-render. A re-render that feeds the fired value back applies nothing. The store recognizes its own emission, so a consumer lagging several drag steps behind never snaps the selection back to an older step. Any other value applies to the store without firing; a value with ananchormoves the active cell to the anchor (a normal active-cell move, which firesonActiveCellChange).defaultSelection: seeds the store once at the first render, then is ignored. Withselectionset too,selectionwins and dev mode warns once.
EMPTY_SELECTION_STATE is a SelectionState with nothing selected. Use it as the initial state
for a controlled selection:
const [selection, setSelection] = useState(EMPTY_SELECTION_STATE);When a value applies, the grid resolves every ref against its current view. A range with a
corner that does not resolve (its row filtered out, or its column hidden) is dropped, as are
unresolvable rowIds and columnIds. An anchor outside its range resets to range.start.
The grid keeps the resolvable subset and dev mode warns once per grid.
Reading the selected values
The getValues() of the second argument extracts the actual cell values under the selection. It
is a function, not a materialized array. A drag-extend fires this callback once per cell the
pointer crosses. Call getValues() only when you need the values (for example, in a debounced
handler). It reads the current range fresh off the store at call time:
onSelectionChange={(selection, { getValues }) => {
if (!selection.range) return;
const values = getValues(); // unknown[][], view-row-major, primary range only
// ...
}}Primary range only
A ctrl-click multi-range selection has additional rectangles in selection.rangeStack.
getValues() reads only selection.range (the primary range), matching the scope of
clipboard copy (resolveCopyScope). Walk rangeStack yourself, with
useDataGridVisibleColumns() and getCellValue(), if you need the other ranges too.
Reading the selected rows
onSelectionChange={(selection, { getRowIds }) => {
const ids = getRowIds(); // string[], view order
setSelectedIds(ids);
}}getRowIds() is the row-level counterpart for bulk actions: delete, assign, or send the selected
rows to a server. It is lazy for the same reason getValues() is.
Unlike the payload's rowIds (the row channel only), getRowIds() covers every selected
row: the rows checked through the marker checkboxes plus every row under range and
rangeStack. That matches what the checkmarks on screen show, so a bulk action never disagrees
with the UI.
getRowIds() returns row ids. Pass them to actions.deleteRowsById(ids) to delete the selected
rows. deleteRows and duplicateRows take view indices instead.
If you need the same values OUTSIDE an onSelectionChange fire, for example a toolbar button
that reads the current selection on click, not from the last change notification, use
useDataGridGetSelectionValues(). It returns a stable callback with the same lazy,
primary-range-only semantics:
const getValues = useDataGridGetSelectionValues();
// later, e.g. in a click handler:
const values = getValues();Clearing the selection
An empty next (range: null and both channels empty) is the cleared signal.
onSelectionChange fires exactly once on the non-empty-to-empty transition, so one clear from
any path (outside click, row marker click, clearSelection(), a cleared range) delivers one empty
payload. Use it to reset an "N cells selected" chip.
3. onSortChange
sortState?: SortSpec[];
onSortChange?: (next: SortSpec[]) => void;This is the full controlled-pair convention. Omit sortState for uncontrolled (store-owned)
behavior. The callback still fires on every user-driven change (a header click,
actions.toggleSort, or setSorts) either way, but a controlled grid only visibly re-sorts once
you pass the new value back in as the prop. See
Sorting, filtering & search for the toolbar UI this drives.
4. onFilterChange
filterState?: FilterNode[];
onFilterChange?: (next: FilterNode[]) => void;This is the same controlled-pair convention as onSortChange. Omit filterState for uncontrolled
behavior. A FilterNode is a filter row or a filter group, so next is the whole tree. The value of a FilterSpec is a single string for
every operator except isBetween, which carries an inclusive [min, max] tuple. Try the Score
column's filter menu with "is between" in the demo above to see that tuple shape in the
inspector. See
Sorting, filtering & search for the toolbar UI these drive.
5. onFill
onFill?: (args: FillArgs) => void;
// FillArgs = { source: GridRect; target: GridRect; values: string[][]; preventDefault: () => void }This fires on a fill-handle drag release or the fillDown and fillRight keymap actions, before
the fill is applied. Call args.preventDefault() to veto it entirely: nothing is written, and the
selection does not expand. Fill itself lives in the separate data-grid-fill add-on. This is
an option to that add-on's useDataGridFill() hook, not a DataGrid or
DataGridRoot prop. See Fill handle.
6. onCellClick
onCellClick?: (ctx: CellClickCtx<TData, unknown>, event: MouseEvent) => void;
// CellClickCtx = { value, row, column, rowId, columnId, viewRowIndex, viewColumnIndex }onCellClick fires once per plain click on any non-skeleton, non-pinned-row cell. Its ctx carries
the cell's value, row, column, row and column identity, and view indices. A row-level reaction
reads ctx.row, ctx.rowId, and ctx.viewRowIndex. It is not a veto point (contrast the
preventDefault() of onFill). It is a pure notification, fired alongside whatever the click
already does (select the cell, toggle a checkbox). It attaches to the cell's own existing DOM
click handler, with no new per-cell subscription, so leaving it unset costs nothing.
To show the clicked row in a side panel, see Open a detail panel from a row.
Not the same as selection
A click always updates selection and activeCell too (see onSelectionChange above).
onCellClick is for "I clicked THIS cell" as a discrete event, not for tracking the resulting
selection state. Use it with onSelectionChange if you need to know what the user clicked, and
where that left the selection.
Typed callbacks in split composition
<DataGrid<TData>> infers TData from its own data or defaultData prop, so
getRowClassName, getCellClassName, and onCellClick are typed automatically.
In split composition (DataGridProvider plus DataGridRoot), onCellClick
lives on the DataGridProvider, which infers TData from its own data prop. The root has no
data prop, so annotate its generic for the class-name callbacks:
<DataGridProvider<Person>
data={people}
columns={columns}
getRowId={(p) => p.id}
onCellClick={(ctx) => console.log(ctx.row.name)} // ctx.row: Person, no cast
>
<DataGridRoot<Person>
getRowClassName={(row) => (row.active ? "bg-accent/20" : undefined)}
/>
</DataGridProvider>DataGridProviderProps<TData> and DataGridRootProps<TData> both default to unknown when you
omit the generic (every existing untyped call site is unaffected). See data-grid.type-test.ts
for the compile-time proof and data-grid-events-demo.tsx for a worked example with
onCellClick.
7. onRenderedRowsChange
onRenderedRowsChange?: (range: { start: number; end: number }) => void;This fires after a render, including once for the initial mount, with the rendered
data-row range. This is the mechanism that useDataGridLazyRows of data-grid-lazy uses to
fetch only scrolled-into-view rows. It is a prop on DataGridProvider and DataGrid. See
Lazy loading.
8. columnLayout + defaultColumnLayout + onColumnLayoutChange + onColumnResizing
type ColumnLayout = {
widths: Record<string, number>;
order: string[];
pins: Record<string, "left" | "right">;
hidden: string[];
};
columnLayout?: Partial<ColumnLayout>;
defaultColumnLayout?: Partial<ColumnLayout>;
onColumnLayoutChange?: (next: ColumnLayout) => void;
onColumnResizing?: (ctx: { columnId: string; width: number }) => void;These props expose width, order, pin, and hidden with the same controlled-pair convention as
selection and sortState. The event fires from the store's commit points, so save-and-restore
and app-driven layouts share one path:
onColumnLayoutChangefires once per committed change: a resize drag release, or an autosize from a double-click or the header menu item (not per drag frame), a width reset (resetColumnWidth), a reorder drop, a pin or unpin, or a show or hide. It fires with the full current snapshot.widthsholds only columns the user actually resized.orderis always the complete current column-id order, not only the moved id.columnLayout: the controlled value, gesture-first likeselection: a commit writes the store and fires the callback at once, and the grid never waits for your re-render. A re-render that feeds the fired snapshot back applies nothing. Any other value applies without firing, and each given part replaces that part while the omitted parts keep the store's current value:columnLayout={{ widths }}controls only widths, and a user reorder survives.defaultColumnLayoutseedscolumnWidths,columnOrder,hiddenColumns, and per-columnpinonce at mount, layered over theColumnDefdefaults (the existingresolveColumnWidthprecedence: a layout value beats a column's ownwidth). It is a seed, not a controlled prop: a value that changes after mount does nothing.onColumnResizingis the in-progress counterpart. It fires on every live width write during a resize drag (per drag frame, throughactions.setColumnWidth), not only once at the commit point. This is useful for a live width readout in a custom UI. If you only care about the final persisted width, useonColumnLayoutChangeinstead. Resizing a column firesonColumnResizingmany times, butonColumnLayoutChangefires exactly once, at drag release.
For app-driven writes, actions.setColumnLayout(parts) replaces the given parts and commits
once, like the gestures.
See Columns for the localStorage persistence recipe.
9. Presence read-back: the two-sided contrast
const { setPresenceHighlights, storeApi } = useDataGridPresence();
useDataGridPresenceHighlights(storeApi): readonly PresenceHighlight[];Presence is the odd one out: it is a setter plus read hook pair, not a callback. Your
transport handler calls setPresenceHighlights (inbound) and useDataGridPresenceHighlights(storeApi)
reads it back (outbound), with zero grid-owned network code in between. It lives in the separate
data-grid-presence add-on, registered through the grid's plugins seam, and is not a
core event source. See Multiplayer presence for the full
write-up, including how to broadcast your own selection with onSelectionChange.
10. Edit lifecycle and cell events
onActiveCellChange?: (next: ViewCellRef | null) => void;
onEditStart?: (cell: ViewCellRef) => boolean | void;
onEditEnd?: (event: GridEditEndEvent) => void;
onValidationError?: (event: GridValidationErrorEvent) => void;
onCellDoubleClick?: (ctx: CellClickCtx<TData, unknown>, event: MouseEvent) => void;
onCellContextMenu?: (ctx: CellClickCtx<TData, unknown>, event: MouseEvent) => void;The first four are store events. They fire from the actions layer with the same zero-re-render guarantee as the other callbacks on this page.
onActiveCellChangefires when the active cell becomes a different cell or is cleared (null). The active cell follows its record and its column. When a sort, a filter, a data change, or a column hide, reorder, or pin moves them, the active cell moves with them, and the event fires with the new view position. The event also fires when another record comes under the same active cell: a lazy row that loads, or a clamp. Re-selecting the same cell on the same record and column does not fire. A rebuild that keeps the record and the column at their view position does not fire. A value-only write to the record under the active cell does not fire. The payload is aViewCellRef: the cell's stablerowIdandcolumnIdplus theviewRowIndex/viewColumnIndexit occupied when the event fired.onEditStartfires right before an edit session opens, from Enter or F2, type-to-replace, or a double-click, with the target cell'sViewCellRef. Returnfalseto veto the edit. The edit then does not start at all, and no other event fires for it. Cells of a type that declaresactivate(likecheckbox) toggle instead of editing, so they never reachonEditStart.onEditEndfires with aresultofcommit,cancel, orrejected.commitfires after the session closes.cancelfires when the draft is discarded: on Escape, when a filter, a hidden column, or a data change removes the edited cell from the view, and when a secondstartEditingreplaces the open session. Each session ends with exactly onecommitorcancel.rejectedfires when a commit attempt fails validation and the session stays open. It fires once per rejected attempt.GridEditEndEventis aViewCellRefplusresult:{ rowId, columnId, viewRowIndex, viewColumnIndex, result }. The committed value is not included.onDataChangealready carries it.- A filter, hidden column, or data change that drops the edited cell cancels the edit and clears the active cell. This happens even when other rows stay in the view.
- When the active cell's row or column leaves the view, the grid clamps the active cell to its
view position, or to the nearest in-view cell, and
onActiveCellChangefires. When the clamp moves the active cell, the selection collapses onto the clamped cell. When the view becomes empty, the grid clears the active cell and the selection's current range, and firesonActiveCellChangewithnull. onValidationErrorfires after a bulk paste, a fill, or anupdateCellscall settles, only when at least one cell was rejected by validation. The accepted cells are applied first, so theironDataChangefires before this event.GridValidationErrorEventis{ source: "paste" | "fill" | "stream" | "replace", rejected }.updateCellsandupdateRowsreport"stream", or"replace"for a find & replace batch. Each rejected entry is{ rowId, columnId, viewRowIndex, value, message }. For a row outside the view,viewRowIndexis-1. Single-cell edits do not use this event.onEditEndwithresult: "rejected"covers that surface.
A commit's events arrive in this order: onActiveCellChange (when the active cell moves) and
onSelectionChange, then onDataChange, then onEditEnd. With an async validate schema, onEditEnd waits for the
schema to settle. The editor stays open while pending is true.
The last two are DOM notifications in the onCellClick sense:
onCellDoubleClickfires on a double-click on a cell. It fires even on areadOnlygrid, and it is not a veto point. The grid still starts the edit unless youronEditStartvetoes it.onCellContextMenufires on a right-click on a cell. It is a pure notification. Thedata-grid-context-menuadd-on listens at the root and is unaffected.
A double-click or right-click inside the open editor (its inline input, or the portaled date, select, and long-text popover) fires neither event.
Both receive the same CellClickCtx shape as onCellClick, plus the native MouseEvent.
Zero re-render guarantee
Every callback above fires from the store's actions layer, a plain DOM handler already attached
to the cell or row, or one storeApi.subscribe registered once outside React (never from a new
per-cell or per-row subscription, so firing them does not re-render a memoized
useDataGridRow or useDataGridCellState subscriber. setPresenceHighlights of
data-grid-presence gives the same guarantee.
Not every event is documented here
DataGridSyncProps in store/types.ts is the source of truth for the complete list of sync props and callbacks. See the
curated subset in API reference for the rest, or read the file directly
for anything deeper.
Unsupported features
- Clipboard notification (
onCopy/onPaste):onDataChangealready fires for a paste (change.source === 'paste').onValidationErrorreports a paste's rejected cells. There is no separate copy event. - Raw scroll-position callback:
onRenderedRowsChangealready reports the rendered row range on every scroll-driven change. There is no lower-level pixel-position callback.