gridcn

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 or edit a cell, drag-select, sort/filter a column, 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 or join-operator changeonFilterChange + join4. onFilterChange + join
Intercept or veto a fill-handle dragonFill5. onFill
React to a plain click on a cell or rowonCellClick + onRowClick6. onCellClick + onRowClick
Fetch rows for the rendered window on demandonRowWindowChange7. onRowWindowChange
Save and restore a user's column layoutonColumnLayoutChange + defaultColumnLayout + onColumnResizing8. onColumnLayoutChange + defaultColumnLayout + 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 / onSortChangeYes — prop omitted keeps uncontrolled (store-owned) statePass sortState back in
Uncontrolled + eventsdefaultColumnLayout / onColumnLayoutChangeNo — only a one-time initial seedPass the saved snapshot as defaultColumnLayout next mount
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

onSelectionChange does not fit any of the three exactly. Like the controlled pair, it fires on every change, but there is no selection prop to feed back in. The active cell and range live entirely in the store, so it is read-only-outbound, closer in spirit to onRowWindowChange.

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, or a row insert, delete, or duplicate. It passes the next full data array and an id-keyed delta batch, change.ops. Each op is an update, insert, or delete with the row id, never an index, because ids survive sort and filter, and indices do not. Every mutation path normalizes to this one shape, which is what makes undo and redo (data-grid-history) and CSV/xlsx export work generically over any consumer's row type.

This is the real shape of one op, 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 DataChange<TData> = {
  ops: DataOp<TData>[];
  source: "edit" | "paste" | "fill" | "delete" | "row-op" | "import" | "history" | "stream";
};

An update op 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.

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) should expect to go blank after a delete.

onDataChange fires the same way whether you passed data (controlled: your app owns the array and must feed it back in) or defaultData (uncontrolled: the grid already applied the change internally, and the callback is only a notification). See the Quick start for the defaultData opt-in and the API reference for the exclusivity rule.

2. onSelectionChange

onSelectionChange?: (next: GridSelection, details: SelectionChangeDetails) => void;
// SelectionChangeDetails = { getValues: () => unknown[][]; getRowIds: () => string[] }

This fires on every committed selection change: a click, a range extend, a row, column, or all selection, or a clear. It fires from the store actions layer, not a React effect, so it has no render dependency. It adds no extra subscription in row or cell components (the perf suite's zero-render probes cover this, see below).

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.

There is no separate onActiveCellChange. The active cell is already part of GridSelection (selection.current.cell), so one callback covers both.

Reading the selected values

Extracting the actual cell values under a selection used to mean hand-rolling a walk over selection.current.range plus useDataGridVisibleColumns() and getCellValue() yourself. The getValues() of the second argument does this for you. It is deliberately a function, not a materialized array. A drag-extend fires this callback once per cell the pointer crosses. Building a full value matrix eagerly on every step costs time even on drags that never read it. Call getValues() only when you need the values, for example inside an if that fires on drag end, or in a debounced handler. It reads the current range fresh off the store at call time:

onSelectionChange={(selection, { getValues }) => {
  if (!selection.current) 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.current.rangeStack. getValues() reads only selection.current.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 getValues(), it covers both selection channels: rows checked through the marker checkboxes, and every rectangle in a cell selection including rangeStack. That matches what the checkmarks on screen show, so a bulk action never disagrees with the UI.

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

onSelectionCleared?: () => void fires exactly once when the selection transitions from non-empty to empty, from any clear path (outside click, gutter click, clearSelection(), a cleared range). Use it to reset an "N cells selected" chip; onSelectionChange also fires on that transition with the empty selection.

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 + join

filterState?: FilterSpec[];
onFilterChange?: (next: FilterSpec[]) => void;
joinOperator?: FilterJoinOperator;
onJoinOperatorChange?: (next: FilterJoinOperator) => void;

This is the same controlled-pair convention as onSortChange. Omit filterState or joinOperator for uncontrolled behavior. 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. onJoinOperatorChange fires separately from onFilterChange, because the join operator ("and" or "or") and the filter list are independent pieces of state. 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 + onRowClick

onCellClick?: (ctx: CellClickCtx<TData, unknown>, event: MouseEvent) => void;
onRowClick?: (ctx: RowClickCtx<TData>, event: MouseEvent) => void;
// CellClickCtx = { value, row, column, rowIndex, columnIndex }
// RowClickCtx = { row, rowIndex }

Both fire from the same plain click on any non-skeleton, non-pinned-row cell. onRowClick fires once per click regardless of which column was clicked, and onCellClick fires with the specific cell's value, row, column, and indices. Neither is a veto point (contrast the preventDefault() of onFill). They are pure notifications, fired alongside whatever the click already does (select the cell, toggle a checkbox). They attach to the cell's own existing DOM click handler, with no new per-cell subscription, so leaving both unset costs nothing.

Not the same as selection

A click always updates selection and activeCell too (see onSelectionChange above). These two callbacks are for "I clicked THIS cell/row" as a discrete event, not for tracking the resulting selection state. Use both together 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, onCellClick, and onRowClick are typed automatically. Composing directly (DataGridProvider plus DataGridRoot) has no such prop on DataGridRoot to infer from, because data lives on the sibling DataGridProvider instead. So annotate TData explicitly:

<DataGridRoot<Person>
  onCellClick={(ctx) => console.log(ctx.row.name)} // ctx.row: Person, no cast
  onRowClick={(ctx) => console.log(ctx.row.age)}
  getRowClassName={(row) => (row.active ? "bg-accent/20" : undefined)}
/>

DataGridRootProps<TData> defaults to unknown when you omit the generic (every existing untyped call site is unaffected), and all four callback props narrow together from the one annotation. See data-grid.type-test.ts for the compile-time proof and data-grid-events-demo.tsx for a worked example with onCellClick and onRowClick.

7. onRowWindowChange

onRowWindowChange?: (range: { start: number; end: number }) => void;

This fires after a row-window commit, 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 also a DataGridRoot prop. See Lazy loading.

8. onColumnLayoutChange + defaultColumnLayout + onColumnResizing

type ColumnLayout = {
  widths: Record<string, number>;
  order: string[];
  pins: Record<string, "left" | "right">;
  hidden: string[];
};

defaultColumnLayout?: ColumnLayout;
onColumnLayoutChange?: (next: ColumnLayout) => void;
onColumnResizing?: (columnId: string, width: number) => void;

"Persist my users' column layout" is a top data-grid request. Without these props, the only path was the undocumented storeApi.subscribe() escape hatch. Width, order, pin, and hidden were mutable only through imperative actions, with no change notification and no restore path. These two props close that gap as uncontrolled-with-events, deliberately not a fully-controlled overlay. A controlled columnLayout prop fights the ColumnDef seeds that it is layered over. It also roughly triples the surface for a save-and-restore need that does not require per-render ownership:

  • onColumnLayoutChange fires once per committed change: a resize drag release or double-click autosize (not per drag frame), 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.
  • 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 not a controlled prop. Passing a new value after mount does nothing, so pair it with onColumnLayoutChange for save and restore. Do not expect it to react to later prop changes.
  • 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.

See Columns for the localStorage persistence recipe.

9. Presence read-back — the two-sided contrast

const { setPresenceHighlights, storeApi } = useDataGridPresence();
useDataGridPresenceHighlights(storeApi): readonly PresenceHighlight[];

Every event above is outbound-only, from the grid to the consumer. Presence is the odd one out. It is a setter plus read hook pair, not a callback. Your transport handler calls setPresenceHighlights directly (inbound), and useDataGridPresenceHighlights(storeApi) reads it back (outbound), with zero grid-owned network code in between. Presence itself lives in the separate data-grid-presence add-on, registered into the core through the generic overlayPlugins seam. It is not a core event source, but this page includes it for the contrast. The demo's "Simulate presence highlight" button round-trips a fake highlight through exactly this path. See Multiplayer presence for the full write-up, including how to broadcast your own selection with onSelectionChange.

Zero re-render guarantee

Every callback above fires from one of three places: the store's actions layer, a plain DOM handler already attached to the cell or row for other reasons (onCellClick, onRowClick), or, for onSelectionChange, one storeApi.subscribe registered once outside React. None of them fire 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.

Not currently supported

  • Editing lifecycle (onEditStart/onEditStop) — the single post-commit DataChange batch of onDataChange already covers "what changed". Use it unless you must intercept mid-edit, for example to block navigation while editing.
  • Clipboard notification (onCopy/onPaste)onDataChange already fires for a paste (change.source === 'paste'). There is no separate copy event.
  • Raw scroll-position callbackonRowWindowChange already reports the rendered row range on every scroll-driven change. There is no lower-level pixel-position callback.

On this page