---
id: "packages/active-grid/editing"
title: "Editing"
description: "Cell, row and batch editing, validation, commits, and undo and redo."
url: "/packages/active-grid/editing"
locale: "en"
revision: "b83a39a419c6f5cd"
---

# Editing

> [!NOTE]
> **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:

| `editMode` | Required callback | Notes |
| --- | --- | --- |
| `'cell'` (default) | `onDataCommit` | Fires per committed cell. |
| `'row'` | `onDataCommit` | Fires once per committed row. |
| `'batch'` | `onBatchCommit` | Fires 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'`):

| Type | Editor | Input |
| --- | --- | --- |
| `text` | Text input | A plain `<input>`; stores the typed string |
| `number` | Number input | The 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 |
| `date` | Date picker | A 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) |
| `select` | Select dropdown | A native `<select>` with `meta.editOptions` or `meta.filterOptions` |
| `boolean` | Checkbox | The 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

| Key | Action |
| --- | --- |
| Enter | Commit the edit and move to next editable cell in the same column |
| Escape | Cancel the edit, restore original value |
| Tab | Commit 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](#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](#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 `undefined`s 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);
  }}
/>
```

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `enableUndoRedo` | `boolean` | `true` when `onDataCommit` is set, else `false` | Capture committed edits in an in-memory history. Ctrl/Cmd+Z undoes, Ctrl/Cmd+Y redoes. |
| `maxUndoDepth` | `number` | `50` | Maximum 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.
