Active Foundation

Search documentation

Search all pages, headings and text, in the current package or topic or in all documentation.

Editing

Cell, row and batch editing, validation, commits, and undo and redo.

@workspace/active-grid 11.0.0Status unknowncontent differs from the recorded release 11.0.0baseline ce081dc3

The package's own guide

This guide is the package's docs/guides/editing.md, included unchanged from the package as it is in Foundation. Links to the package's internal design documents keep their text.

Active Grid supports inline editing in cell mode (one cell at a time), row mode (an entire row), and batch mode (buffered changes). For API reference see ../api/grid-props.md (editMode, editTrigger, commitOnBlur, onDataCommit) and ../api/cells-utils.md (EditingCommitPayload).

Each edit mode needs its own commit callback#

editMode and the commit callback are a pair. There is exactly one commit path per mode, and supplying the other one does not substitute for it:

editModeRequired callbackNotes
'cell' (default)onDataCommitFires per committed cell.
'row'onDataCommitFires once per committed row.
'batch'onBatchCommitFires once for all pending changes, on Save all.

Editing is fail-closed. When the callback that matches the mode is missing, the grid offers no editing at all: cells are not editable and the BatchToolbar does not render. A dev-mode warning, once per gridId, names the missing callback.

This is deliberate. Previously the mismatch left the cells editable while the commit path dead-ended — editMode="batch" with only onDataCommit showed a "Save all" button that returned false and discarded the batch, and editMode="cell" with only onBatchCommit swallowed every commit — so the user's work disappeared without any signal. Offering nothing is the honest state. See ../DONE.md § 7.5.0.

A grid with no editing callbacks at all is an ordinary read-only grid and is not warned about.

Cell Editing#

Setup#

TSX
<ActiveGrid  editMode="cell"  onDataCommit={async (payload) => {    await updateUser(payload.rowId, payload.changes);  }}  columns={[    {      accessorKey: 'name',      header: 'Name',      meta: {editable: true},    },  ]}/>

Trigger#

The editTrigger prop controls how editing starts:

  • 'doubleClick' (default) — double-click an editable cell.

  • 'click' — a single click starts editing (spreadsheet-style).

Pressing Enter on a keyboard-focused cell also starts editing. The cell content is replaced with an input editor.

With editTrigger="click", a click that bubbles up from the already-open editor (for example choosing an option in a <select>, whose element lives inside the cell) does not restart the edit — the in-progress value is preserved rather than re-seeded from the original row.

Editor Types#

The editor component is selected based on meta.editType (falls back to meta.filterType, then 'text'):

TypeEditorInput
textText inputA plain <input>; stores the typed string
numberNumber inputThe grid's own number input: a text input (inputMode="decimal") that reads the grid locale's number notation through the one portable number parser (classifyNumberInput, active-i18n): the locale's decimal separator, its grouping on paste, and every Unicode digit set. Never a native type="number", which expects a decimal point
dateDate pickerA text input in the grid locale's date notation with a calendar popover; stores the calendar day in the shape the cell already had (an ISO YYYY-MM-DD string, or a Date at UTC midnight)
selectSelect dropdownA native <select> with meta.editOptions or meta.filterOptions
booleanCheckboxThe grid's own checkbox primitive; a toggle commits at once

Commit Flow#

  1. User edits the cell value.

  2. User presses Enter or clicks outside the cell.

  3. If meta.validate is defined, validation runs (sync or async).

  4. If validation passes, onDataCommit is called with the payload.

  5. If validation fails, the commit is blocked and the cell shows an error icon; the message is in its tooltip and its aria-label.

  6. User can press Escape to cancel and restore the original value.

Keyboard Shortcuts in Cell Edit Mode#

KeyAction
EnterCommit the edit and move to next editable cell in the same column
EscapeCancel the edit, restore original value
TabCommit and move to next editable cell

Input method editors (IME)#

While an input method editor composes (Japanese, Chinese, Korean and many other inputs), Enter confirms a candidate, Escape discards it and Tab and the arrows move through candidates. Those keys belong to the IME: during a composition the grid neither commits, nor cancels, nor moves the active cell. After compositionend the keys above apply again. The grid recognises a composing key by KeyboardEvent.isComposing, or by keyCode 229 for engines that end the composition before the confirming keydown.

This is proven with synthetic composition events in unit and browser tests; behaviour under each native IME and browser is not yet claimed.

Enter → Next Editable Cell#

When Enter is pressed during editing:

  1. The current cell value is committed via onDataCommit.

  2. If the commit succeeds and the edit is still the one in progress, the grid searches downward in the same column for the next editable cell. An edit the user opened while the write was running stays open; see An ended edit never reaches the consumer.

  3. If a next editable cell is found, editing starts on that cell automatically.

  4. If no editable cells remain below, editing ends.

This enables fast data-entry workflows where users can type a value, press Enter, and immediately continue editing the next row — matching AG Grid behavior.

Row Editing#

Setup#

TSX
<ActiveGrid  editMode="row"  onDataCommit={async (payload) => {    await updateUser(payload.rowId, payload.changes);  }}  columns={[    {accessorKey: 'name', header: 'Name', meta: {editable: true}},    {accessorKey: 'email', header: 'Email', meta: {editable: true}},    {      accessorKey: 'role',      header: 'Role',      meta: {editable: true, editType: 'select', editOptions: roles},    },  ]}/>

Behavior#

In row editing mode, right-clicking a row and selecting "Edit row" puts all editable cells in the row into edit mode simultaneously. The right-click context-menu on that row switches its "Edit row" item to "Save" + "Cancel" while the row is being edited.

Commit/cancel options:

  • Enter in any editable cell commits the whole row.

  • Escape in any editable cell cancels the whole row.

  • Right-click → "Save" / "Cancel" — the row's context-menu switches its single "Edit row" item to a Save + Cancel pair while the row is in edit-mode. Implementation lives in src/components/menus/actions/row-edit-actions.tsx (internal).

There is no inline floating toolbar on the row. Save/cancel discoverability is via the keyboard shortcuts or the context-menu.

All field values are tracked together. Validation runs on commit for all fields.

Validation#

Synchronous Validation#

typescript
{  meta: {    editable: true,    validate: (value: string, row: User) => {      if (!value.trim()) return 'Name is required';      if (value.length < 2) return 'Name must be at least 2 characters';      return undefined; // no error    },  },}

Asynchronous Validation#

typescript
{  meta: {    editable: true,    validate: async (value: string, row: User) => {      const exists = await checkEmailExists(value);      if (exists && value !== row.email) return 'Email already in use';      return undefined;    },  },}

During async validation the cell shows a spinner and the commit is blocked until validation completes. The same spinner covers the consumer commit that follows, so it means "working" rather than specifically "validating" -- the distinction is rarely meaningful to the user. An edit that ends while its validation is pending never commits; see An ended edit never reaches the consumer.

What validation guarantees#

  • It runs before the consumer is told anything. A rejected value never reaches onDataCommit (or onBatchCommit), so it cannot be written.

  • A validator that throws blocks the commit. An exception is treated as a rejection, not an approval; the cell shows the grid.edit.validationFailed message. A crashing validator must not wave a value through.

  • In row mode one failing cell blocks the whole row. Every column with a validator is checked and all failures are reported at once, so the user sees every problem in one pass. Nothing is written partially, and the entered values stay in the editor.

  • In batch mode validation runs when the cell is committed to the dirty buffer, not at "Save All". Validation belongs to the input: the error can appear on the cell the user is still in, and an invalid value never enters the buffer. Known limitation: a value reaching the buffer by any route other than that cell commit is not validated.

  • The error clears when you edit the cell again.

Validation messages come from your validator, so they are already in your locale. Two fallbacks ship with the grid for cases where the grid itself has to speak: grid.edit.validationFailed (validator threw) and grid.edit.invalidValue (valueParser could not parse the input).

Value Parser#

By default, editor values are stored as-is (strings from the text editor, numbers from the number editor). For columns that need type conversion — such as formatted currency or locale-specific numbers — use valueParser:

typescript
{  accessorKey: 'salary',  header: 'Salary',  meta: {    editable: true,    editType: 'text',    semanticType: 'currency',    currencyCode: 'EUR',    valueParser: (value: string) => {      // "1.000,50" → 1000.50      const cleaned = value.replace(/\./g, '').replace(',', '.');      const num = parseFloat(cleaned);      return Number.isNaN(num) ? undefined : num;    },  },}

The valueParser runs in EditableCellWrapper before updateValue is called. This ensures the editing state always contains the parsed value, not the raw input string.

Returning undefined rejects the input. A parser that cannot make sense of what the user typed should return undefined; the grid then shows the grid.edit.invalidValue message and blocks the commit. Previously that undefined was stored and committed, so the consumer received the column key with no value -- which a Prisma update reads as "leave this field alone" and JSON.stringify drops entirely, failing silently on every layer.

Keep the parser to type conversion, not range enforcement. It runs on every keystroke, and the editors are controlled, so a parser that rejects intermediate input makes the field impossible to fill in incrementally: if 42 is valid but 4 is not, the field clears on the first digit and the second keystroke starts over. Use validate for range and business rules, which runs once at commit; keep valueParser for turning the raw string into the stored type. See ROADMAP.md TD-24.

typescript
// Wrong: the parser enforces the range, so "55" can never be typed — the// first digit is out of range, returns undefined, and the field clears.valueParser: (v: string) => {  const n = Number(v);  if (isNaN(n) || n < 18 || n > 99) return undefined;  return Math.round(n);},
// Right: the parser converts, the validator judges.valueParser: (v: string) => {  const n = Number(v);  return v.trim() === '' || isNaN(n) ? undefined : Math.round(n);},validate: (v: unknown) => {  const n = Number(v);  return isNaN(n) || n < 18 || n > 99 ? t('age.outOfRange') : undefined;},

Note the two undefineds mean different things: from the parser it means "this is not a number at all" and surfaces as grid.edit.invalidValue; from the validator it means "no complaint".

Conditional Editing#

Control which cells are editable based on row data:

typescript
{  accessorKey: 'status',  header: 'Status',  meta: {    editable: (row: User) => row.role !== 'admin', // admins can't change their own status    editType: 'select',    editOptions: statusOptions,  },}

EditingCommitPayload#

The onDataCommit callback receives a payload with all the information needed to persist the change:

typescript
interface EditingCommitPayload<TData> {  rowId: string; // Stable row identifier  originalRow: TData; // Row data before editing  changes: Record<string, unknown>; // Only the changed fields  newData: TData; // Complete row data after edits}

Example: Persist to Server Action#

typescript
async function handleCommit(payload: EditingCommitPayload<User>) {  const result = await updateUserAction(payload.rowId, payload.changes);  if (!result.success) {    throw new Error(result.error); // Error is shown in the cell  }}

If onDataCommit throws, the error message is displayed in the cell/row and the edit state is preserved so the user can correct the value or cancel.

Row ID Tracking#

Editing state is tracked by rowId (not row index). This prevents data corruption when the grid is sorted, filtered, or refreshed while an edit is in progress. If the row being edited is removed from the data (e.g., filtered out), the edit is automatically cancelled.

The rowId is resolved via:

  1. The getRowId prop (if provided).

  2. The id field on the row data (auto-detected).

  3. An error is thrown if neither exists.

Auto-cancel#

The editing hook monitors whether the row being edited still exists in the current data. If the row disappears (due to filtering, sorting, or data refresh), the edit is cancelled with a log message. This prevents committing changes to a row that is no longer visible.

An ended edit never reaches the consumer#

An edit that ends while its validation is still pending does not commit when the validation later settles. The edit ends when:

  • its row leaves the data;

  • it is cancelled (Escape, the row menu's Cancel, a blur with commitOnBlur={false});

  • another edit starts (a different cell or row);

  • in batch mode, "Discard all" runs;

  • the grid unmounts.

Such an edit calls neither onDataCommit nor, in batch mode, fills the dirty buffer that "Save all" sends. Its validation result is not written into a newer edit either: a message from the old validator never appears under a cell the user opened later. The backend is not the grid's cancellation mechanism; what the grid shows as cancelled is never written.

The boundary is the consumer call. Once onDataCommit has been called, the write is the consumer's: an edit started meanwhile stays open, and the completed write does not close it. A completed write is also no permission to move on: Enter and Tab open the next cell only when the committed edit is still the one in progress. If the user opened another cell while the write was running, that editor stays open with exactly what was typed into it. When the grid unmounts during a write, the write finishes and the grid does nothing with it afterwards: no row patch, no next editor. The same holds for "Save all" in batch mode.

With commitOnBlur, moving to another editable cell commits the open edit first and opens the next one once that commit is accepted, unless another edit began in the meantime. A value the validator refuses keeps the open edit, with its message, and the next cell does not open.

Editing while "Save all" runs#

The grid stays editable while onBatchCommit runs. When the save completes, it settles only the cells it sent:

  • a cell still exactly as it was sent is saved and no longer dirty;

  • a cell edited again after the save began keeps its newer value and stays dirty, now against the saved value;

  • a cell first edited during the save stays dirty, untouched;

  • a cell edited back to its old value during the save shows that old value and is dirty against the saved one, so the next save restores it;

  • an open editor keeps its draft.

When onBatchCommit returns rows, a returned row replaces the displayed row, with every cell that is still dirty kept at its newer value. The value the consumer returned for a sent cell becomes the value that cell is compared against. "Save all" stays available as long as anything is dirty, and the next save sends exactly the newer work.

A rejected save settles nothing: every change, sent or newer, stays dirty. A second "Save all" while one is pending sends nothing and returns false; the toolbar button is disabled meanwhile.

Commit / Cancel on Outside Click / Blur#

When focus or the pointer leaves the cell for somewhere outside the grid, the open edit is resolved according to commitOnBlur:

  • commitOnBlur={false} (default) — the edit is cancelled and the original value restored. Only Enter/Tab commit. This matches AG Grid's default and prevents orphaned editing state.

  • commitOnBlur={true} — the edit is committed (spreadsheet-style "click away saves").

Two handlers cover this so the behavior is consistent whether the click lands on a focusable element or not:

  • A grid-wrapper onBlur (focusout) fires when focus moves to another focusable element outside the grid.

  • A document-level mousedown handler (attached only while commitOnBlur is on) covers a click on a non-focusable element outside the grid (page background, a heading) — such a click does not move focus, so it produces no focusout.

Editor-owned popovers are excluded. Some editors render an interactive layer in a portal outside the grid DOM (notably the date-picker calendar). Focus or a click landing inside such a portal ([data-slot="popover-content"]) neither commits nor cancels — the user is still interacting with the open editor. The edit resolves only once focus/pointer truly leaves the grid for an element outside it.

Select Editor Options#

For select editors, options come from (in priority order):

  1. meta.editOptions — Explicit editor options.

  2. meta.filterOptions — Shared options (used by both filter and editor).

typescript
{  meta: {    editable: true,    editType: 'select',    editOptions: [      {label: 'Active', value: 'active'},      {label: 'Suspended', value: 'suspended'},      {label: 'Archived', value: 'archived'},    ],  },}

Undo / Redo#

Ctrl+Z / Ctrl+Y undo and redo committed cell edits. Undo/redo is on by default whenever onDataCommit is set; enableUndoRedo={false} turns it off. History lives in the mounted grid and is dropped on unmount. There is no persistence across sessions.

TSX
<ActiveGrid  gridId="users"  data={users}  columns={columns}  enableUndoRedo  maxUndoDepth={50}  onDataCommit={async (payload) => {    await updateUser(payload.rowId, payload.changes);  }}/>
PropTypeDefaultDescription
enableUndoRedobooleantrue when onDataCommit is set, else falseCapture committed edits in an in-memory history. Ctrl/Cmd+Z undoes, Ctrl/Cmd+Y redoes.
maxUndoDepthnumber50Maximum number of undoable edits retained. Older edits are discarded.

Integration with onDataCommit#

Undo/redo operates on the same onDataCommit-flow as regular edits. Undoing a committed change calls onDataCommit again with the inverse payload: changes carries the previous value, originalRow the row as the edit left it, and newData the restored row. Redo sends the original edit again. The returned row, or else the payload's newData, is patched into the grid. When that call throws, the entry goes back onto the undo stack. Your server-action must accept that the same edit can run repeatedly in either direction.

Idempotent server-actions are recommended:

typescript
await updateUser(payload.rowId, payload.changes);// Same call works for "set name to 'Bob'" and "set name back to 'Alice'"

If your action has side-effects (sending an email, audit-logging), gate those on the committed transition rather than every call:

typescript
const before = await getUser(payload.rowId);await updateUser(payload.rowId, payload.changes);if (before.email !== payload.newData.email) {  await sendEmailChangeAudit(payload.rowId);}

History scope#

  • History is per mounted <ActiveGrid>; it is not keyed by gridId.

  • History is cleared on unmount — switching pages drops the history.

  • History does not sync to URL or localStorage. It is in-memory only.

  • One entry per commit that changed exactly one column. A cell-mode commit is recorded; a row-mode commit is recorded only when it changed a single column, and a row commit that changed several columns is not recorded at all.

  • Batch mode has no undo/redo. With editMode="batch" the shortcuts do nothing; "Discard all" is the way back.

  • A new recorded edit clears the redo stack.

Keyboard shortcuts#

  • Ctrl+Z (Cmd+Z on macOS) — undo last edit

  • Ctrl+Y or Ctrl+Shift+Z (Cmd+Y or Cmd+Shift+Z on macOS) — redo

The letters are matched case-insensitively, so the shortcuts also work with Caps Lock on.

The shortcuts are read from a focused body cell, and they need keyboard navigation (enableKeyboardNavigation, on by default). They are not read while an editor is open: inside the editor's input the browser's own text undo applies, as it does in any text field outside the grid.

Disabling for specific columns#

Columns with meta.editable === false are not tracked in history. To exclude an editable column from undo (e.g., a free-form notes field where users prefer their own browser-undo), there is currently no per-column opt-out — the whole grid's undo is on or off via the prop.

This page as Markdown