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.
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 or join-operator change | onFilterChange + join | 4. onFilterChange + join |
| Intercept or veto a fill-handle drag | onFill | 5. onFill |
| React to a plain click on a cell or row | onCellClick + onRowClick | 6. onCellClick + onRowClick |
| Fetch rows for the rendered window on demand | onRowWindowChange | 7. onRowWindowChange |
| Save and restore a user's column layout | onColumnLayoutChange + defaultColumnLayout + onColumnResizing | 8. onColumnLayoutChange + defaultColumnLayout + 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 | Yes — prop omitted keeps uncontrolled (store-owned) state | Pass sortState back in |
| Uncontrolled + events | defaultColumnLayout / onColumnLayoutChange | No — only a one-time initial seed | Pass the saved snapshot as defaultColumnLayout next mount |
| 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 |
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:
onColumnLayoutChangefires 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.widthsholds only columns the user actually resized.orderis always the complete current column-id order, not only the moved id.defaultColumnLayoutseedscolumnWidths,columnOrder,hiddenColumns, and per-columnpinonce at mount, layered over theColumnDefdefaults (the existingresolveColumnWidthprecedence: a layout value beats a column's ownwidth). It is not a controlled prop. Passing a new value after mount does nothing, so pair it withonColumnLayoutChangefor save and restore. Do not expect it to react to later prop changes.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.
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-commitDataChangebatch ofonDataChangealready covers "what changed". Use it unless you must intercept mid-edit, for example to block navigation while editing. - Clipboard notification (
onCopy/onPaste) —onDataChangealready fires for a paste (change.source === 'paste'). There is no separate copy event. - Raw scroll-position callback —
onRowWindowChangealready reports the rendered row range on every scroll-driven change. There is no lower-level pixel-position callback.