gridcn1.3.0

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.

Name
Email
Age
Score
Silent Tiger
user0@example.com
63
4
Quick Lion
user1@example.com
67
89
Swift Wolf
user2@example.com
40
80
Eager Lion
user3@example.com
50
51
Eager Tiger
user4@example.com
45
83
Silent Wolf
user5@example.com
24
15
Swift Lion
user6@example.com
34
62
Eager Lion
user7@example.com
21
42
Eager Eagle
user8@example.com
25
35
Bold Tiger
user9@example.com
19
52
Bold Bear
user10@example.com
42
28
Swift Wolf
user11@example.com
47
9
Swift Bear
user12@example.com
41
45
Eager Wolf
user13@example.com
35
44
Swift Eagle
user14@example.com
65
51
Quick Lion
user15@example.com
22
28
Bold Wolf
user16@example.com
30
76
Swift Eagle
user17@example.com
19
26
Eager Eagle
user18@example.com
35
70
Eager Bear
user19@example.com
33
67
Silent Lion
user20@example.com
48
89
Silent Tiger
user21@example.com
62
40
Eager Tiger
user22@example.com
51
82
Silent Lion
user23@example.com
26
16
Bold Eagle
user24@example.com
26
38
Bold Lion
user25@example.com
49
3
Quick Tiger
user26@example.com
26
41
Bold Eagle
user27@example.com
50
10
Swift Tiger
user28@example.com
55
6
Silent Lion
user29@example.com
35
42

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

NeedCallbackSection
React to a data edit, paste, fill, or row changeonDataChange1. onDataChange
Read the active cell or a range selectiononSelectionChange2. onSelectionChange
React to a sort changeonSortChange3. onSortChange
React to a filter changeonFilterChange4. onFilterChange
Control or observe the quick-search textsearchText / onSearchTextChangeSame controlled-pair convention as 3. onSortChange
Intercept or veto a fill-handle dragonFill5. onFill
React to a plain click on a cell or its rowonCellClick6. onCellClick
Veto or react to an edit starting or endingonEditStart + onEditEnd10. Edit lifecycle and cell events
React to the active cell movingonActiveCellChange10. Edit lifecycle and cell events
React to cells a bulk paste, fill, or updateCells rejectedonValidationError10. Edit lifecycle and cell events
React to a double-click or right-click on a cellonCellDoubleClick + onCellContextMenu10. Edit lifecycle and cell events
Fetch rows for the rendered rows on demandonRenderedRowsChange7. onRenderedRowsChange
Save and restore a user's column layoutcolumnLayout + defaultColumnLayout + onColumnLayoutChange + onColumnResizing8. columnLayout + defaultColumnLayout + onColumnLayoutChange + onColumnResizing
Broadcast or receive a remote user's selectionsetPresenceHighlights / useDataGridPresenceHighlights9. Presence read-back

The three shapes an event follows

ShapeExampleControlled prop?Restore path
Controlled pairsortState / 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 nothingPass the value back in (sortState, selection, or columnLayout)
Data: controlled or uncontrolled opt-indata (controlled) vs defaultData (uncontrolled) / onDataChangeEither. data makes it a controlled pair, defaultData seeds once and the store owns the arrayControlled: pass data back in. Uncontrolled: nothing to restore. The store already has it
Setter + read hookuseDataGridPresence().setPresenceHighlights / useDataGridPresenceHighlights(storeApi)N/A: imperative in, hook outCall 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 an anchor moves the active cell to the anchor (a normal active-cell move, which fires onActiveCellChange).
  • defaultSelection: seeds the store once at the first render, then is ignored. With selection set too, selection wins 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:

  • onColumnLayoutChange fires 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. widths holds only columns the user actually resized. order is always the complete current column-id order, not only the moved id.
  • columnLayout: the controlled value, gesture-first like selection: 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.
  • defaultColumnLayout seeds columnWidths, columnOrder, hiddenColumns, and per-column pin once at mount, layered over the ColumnDef defaults (the existing resolveColumnWidth precedence: a layout value beats a column's own width). It is a seed, not a controlled prop: a value that changes after mount does nothing.
  • onColumnResizing is the in-progress counterpart. It fires on every live width write during a resize drag (per drag frame, through actions.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, use onColumnLayoutChange instead. Resizing a column fires onColumnResizing many times, but onColumnLayoutChange fires 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.

  • onActiveCellChange fires 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 a ViewCellRef: the cell's stable rowId and columnId plus the viewRowIndex/viewColumnIndex it occupied when the event fired.
  • onEditStart fires right before an edit session opens, from Enter or F2, type-to-replace, or a double-click, with the target cell's ViewCellRef. Return false to veto the edit. The edit then does not start at all, and no other event fires for it. Cells of a type that declares activate (like checkbox) toggle instead of editing, so they never reach onEditStart.
  • onEditEnd fires with a result of commit, cancel, or rejected. commit fires after the session closes. cancel fires 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 second startEditing replaces the open session. Each session ends with exactly one commit or cancel. rejected fires when a commit attempt fails validation and the session stays open. It fires once per rejected attempt. GridEditEndEvent is a ViewCellRef plus result: { rowId, columnId, viewRowIndex, viewColumnIndex, result }. The committed value is not included. onDataChange already 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 onActiveCellChange fires. 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 fires onActiveCellChange with null.
  • onValidationError fires after a bulk paste, a fill, or an updateCells call settles, only when at least one cell was rejected by validation. The accepted cells are applied first, so their onDataChange fires before this event. GridValidationErrorEvent is { source: "paste" | "fill" | "stream" | "replace", rejected }. updateCells and updateRows report "stream", or "replace" for a find & replace batch. Each rejected entry is { rowId, columnId, viewRowIndex, value, message }. For a row outside the view, viewRowIndex is -1. Single-cell edits do not use this event. onEditEnd with result: "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:

  • onCellDoubleClick fires on a double-click on a cell. It fires even on a readOnly grid, and it is not a veto point. The grid still starts the edit unless your onEditStart vetoes it.
  • onCellContextMenu fires on a right-click on a cell. It is a pure notification. The data-grid-context-menu add-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): onDataChange already fires for a paste (change.source === 'paste'). onValidationError reports a paste's rejected cells. There is no separate copy event.
  • Raw scroll-position callback: onRenderedRowsChange already reports the rendered row range on every scroll-driven change. There is no lower-level pixel-position callback.

On this page