Skip to content

React table editing — validation, batch, undo

▶ Try it live: open a Mantine starter in StackBlitz — this page’s feature is already wired in src/App.tsx (editable columns + editing() + editHistory() for undo and redo); edit it in the browser, no install. Other UI kits →

▶ See it working: edit cells in the live demo — a real table you can type into, not a recording.

Edit a cell in place by composing editing from @adapttable/<kit>/editing and marking columns editable. Omit the factory and the table never opens an editor — even if columns declare editable. The table never mutates rows; your handler applies the change. See feature composition.

import { useState } from "react";
import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { editing } from "@adapttable/mantine/editing";
interface Person {
id: string;
name: string;
role: string;
status: "Active" | "Planned" | "Blocked";
}
const SEED: Person[] = [
{ id: "1", name: "Aisha", role: "Engineer", status: "Active" },
{ id: "2", name: "Jonas", role: "Designer", status: "Planned" },
];
export function People() {
const [rows, setRows] = useState(SEED);
return (
<DataTable
data={rows}
columns={[
{ key: "name", sortable: true, editable: true },
{
key: "status",
editable: true,
editor: {
type: "select",
options: ["Active", "Planned", "Blocked"],
},
},
{ key: "role", editable: (row) => row.status !== "Blocked" },
]}
rowKey={(r) => r.id}
features={[
editing((row, key, nextValue) => {
setRows((prev) =>
prev.map((r) =>
r.id === row.id ? { ...r, [key]: nextValue as never } : r
)
);
}),
]}
/>
);
}
  • Opt-in. editing() is the switch. Without it, cells stay plain display (package DNA: nothing is pushed on the developer).
  • Per-column. editable is true, false, or (row) => boolean. The editor defaults to "text"; use "number" or { type: "select", options } for the other kits.
  • Keyboard. Double-click / Enter / F2 begins; Enter commits; Escape cancels and restores focus; Tab / Shift+Tab commits and advances to the next editable cell.
  • One-way data flow. The commit payload is (row, key, nextValue) on the handler you pass to editing() — adapters render kit-native inputs; core owns the state machine so every kit behaves the same.
  • Beyond one cell. Row-level edit mode (rowEditing(onRowEdit)) opens every field together and commits one patch; validate / validateRow reject a commit and mark the cells that failed; onEditRollback restores a row after a rejected save, and dirtyIndicators() marks what nobody has confirmed yet.
  • The table never writes to a row. Persistence stays with the host: every commit reaches your handler, and the stored data changes only when you change it.
Prop / field Type Default Description
editing(fn) (row: TRow, key: string, nextValue: unknown) => void — First argument to editing(); its presence enables editing.
editable boolean | ((row: TRow) => boolean) — Whether this column can open an editor (still requires editing()).
editor CellEditor — see the editor set "text" Widget for the active cell: text, number, boolean, date, datetime, time, select, multi-select or custom.
editValue (row: TRow) => string — Draft seed when the displayed cell is formatted but editing needs the raw.
parseValue (draft: string, row: TRow) => unknown — Turns the edited text into the value committed to your edit handler.
labels TableLabels English Override editCell for the activate control’s accessible name.
validate (value, row) => string | undefined | Promise<…> — Column rule: return a message to reject the commit (see below).
validateRow (row) => string | Record<string,string> | undefined — Table rule over the row the edit would produce (cross-field).
applyEdit (row, columnKey, value) => TRow spread How an edit lands on a row for validateRow to judge.

onEditStart, onEditCancel, onEditCommit, onValidationFail and onEditError observe what the editor does. They cannot change it: a throw is swallowed, a return value is ignored. The same events fire for a cell, a row and a batch, and on a mobile card — the commit unit does not change with the layout.

<DataTable
{...props}
features={[
editing(save, {
onEditStart: (e) => analytics.track("edit_start", e.columnKey),
onEditCommit: (e) => analytics.track("edit_commit", e.value),
onEditCancel: (e) => analytics.track("edit_cancel", e.columnKey),
onValidationFail: (e) => toast.error(e.error),
onEditError: (e) => toast.error(e.error),
}),
]}
/>

The payload is EditEvent<TRow>: row, rowId, columnKey (empty for a row or batch), value, previousValue, unit ("cell" / "row" / "batch") and optional error. For onEditError, value is the attempted save and previousValue is the cell’s previous value. Omit every handler and nothing is called.

A refetch, a websocket, another user — the row under an open editor can change. (Live row updates that are not an open-editor fight are realtime.) The table does not merge. It can keep what you typed, take the incoming value, or ask. Silently discarding a draft is the one outcome nobody forgives, so the default is to ask.

<DataTable
{...props}
features={[
editing(save, {
editConflictPolicy: "ask",
rowVersion: (row) => row.updatedAt,
onEditConflict: (conflict) => {
analytics.track("edit_conflict", conflict.columnKey);
},
}),
]}
/>

onEditConflict may return "keep" or "take" to resolve it; returning nothing uses the policy. "ask" puts Keep mine / Take theirs on the editor (data-conflict, the same aria-describedby channel as a validation message) and shows the incoming value (labels.theirsValue) so the reader can see what they would take. Without rowVersion, only the edited column’s stored value counts as a change.

editor Control Commits
"text" (default) text input the string
"number" number input number | null (empty → null)
"boolean" checkbox true / false
"date" date input "YYYY-MM-DD"
"datetime" datetime input "YYYY-MM-DDTHH:mm"
"time" time input "HH:mm"
{ type: "select", options } the kit’s select the chosen value
{ type: "multi-select", options } the kit’s multi-select the chosen values, an array

The date, time and checkbox editors use the platform’s own controls: they are keyboard-complete and localized by the browser, which is a better answer than nine hand-built pickers.

Dates commit strings, not Date objects. "2026-08-13" is a day; a Date is an instant in a timezone the reader never chose, and converting to one moves the day for most of the world. A column storing a Date still seeds its editor correctly — the local parts are read off it — and parseValue turns the committed string back into whatever you store.

A boolean commits on the tick. A checkbox has one gesture, so waiting for Enter would leave a ticked box that changed nothing.

A multi-select seeds itself from a stored array and commits an array back, so a host stores exactly what it gave — no separator to parse, no single-value special case. An empty selection commits [], not "".

Each kit picks several values the way that kit picks several values: Ant Design and Mantine open their own multi-select, MUI and Chakra render a multiple list box, and Radix Themes and Base UI — whose select holds one value by construction — show a group of their own checkboxes. That group is MultiSelectEditorChrome, core’s structure with a Checkbox slot, so all three kits share one accessible group and none of them ships hand-written markup.

An autocomplete, a rich-text field, a colour picker — anything that is not one of the eight. The table keeps everything it already owned; the component owns only what the reader looks at:

{
key: "colour",
editable: true,
editor: {
type: "custom",
render: (ctrl) => (
<SwatchPicker
value={ctrl.draft}
onKeyDown={ctrl.onKeyDown}
firstSwatchRef={ctrl.focusRef}
onPick={(swatch) => {
ctrl.setDraft(swatch);
ctrl.commit();
}}
/>
),
},
}

Double-click / Enter / F2 still activates the cell, focus still returns to it afterwards, Enter still commits, Escape still cancels, Tab still moves on, and validators still gate the commit — none of that is yours to write again. What arrives is draft and the calls that change it: setDraft, commit (for a picker, where choosing IS the gesture), cancel, onKeyDown (wire it to keep the keyboard flow; an editor that owns Enter simply does not call it for Enter), onBlur, and focusRef to point at what should take focus. error, validating and errorId are there so a component can mark itself invalid — the table renders the message either way. label is the accessible name for the control, and conflict is set while a live-update conflict is open on the cell.

The draft is a string, as it is for every editor; parseValue is still how a column turns it into whatever gets stored.

Three moments in a cell’s life, and a formatted column needs a different value at each:

Moment Field For a currency column
Displayed accessor "$1,240.00"
Edited editValue "1240"
Committed parseValue 1240
{
key: "budget",
editable: true,
editor: "number",
accessor: (row) => money.format(row.budget),
editValue: (row) => String(row.budget),
parseValue: (draft) => Number(draft.replace(/[^0-9.-]/g, "")),
}

parseValue receives the draft exactly as typed plus the row, and replaces the editor’s own parsing rather than running after it — so a column that says how to read its drafts is in full control. Return anything your edit handler should receive, including a value no built-in editor produces, such as a Date.

Without it nothing changes: a number editor commits number | null and every other editor commits the raw string.

A validator gates the commit and nothing else. It never rejects a keystroke, never rewrites a value, and never decides what to save — the host still owns persistence, and will usually validate again on the server.

Two levels, because they answer different questions. A column rule knows one value:

const columns: ColumnDef<Task>[] = [
{
key: "title",
editable: true,
validate: (value) =>
String(value).trim() === "" ? "A title is required" : undefined,
},
];

A table rule sees the row the edit would produce, and answers what no single cell can:

<DataTable
{...props}
features={[
editing(apply, {
validateRow: (task) =>
task.end <= task.start
? "The end must come after the start"
: undefined,
}),
]}
/>

Return a string for a row-level problem, or a map of column key → message to mark individual cells — which is how a cross-field rule points at the field the reader should look at. Return nothing to allow the commit. When a column key is not the field (a nested path), pass applyEdit so the rule sees the right row.

A rejected value never reaches your edit handler. The editor stays open holding what the reader typed, and the message is announced rather than only painted. Where it renders depends on the kit: Mantine and MUI put it in their own input’s error slot (their components own the field’s aria-describedby), and every other kit renders data-adapttable-part="edit-cell-error" with role="alert", the editor carrying aria-invalid and aria-describedby pointing at it. Either way the message exists once in the DOM and is read with the field. Escape clears it with the draft.

Both levels may be async — “is this SKU real” is a request. The editor stays open and carries aria-busy while the check runs, and a newer draft supersedes an older check, so a stale answer can never mark a value the reader has already changed. A column with no validator commits synchronously, exactly as before.

Return a promise from your editing() handler and the table knows something the reader cannot see: the value is on its way somewhere. The cell says so until the promise settles — data-save="saving" and aria-busy on the cell — and says why if it rejects, in a live region beside it (data-adapttable-part="edit-cell-save-error", role="alert").

<DataTable
{...props}
features={[
editing(
async (row, key, value) => {
setRows(applyOptimistically(row, key, value));
await api.save(row.id, { [key]: value });
},
{
onEditRollback: (previous, columnKey) => {
setRows((current) => restore(current, previous));
},
formatEditError: (error) => humanize(error),
}
),
]}
/>

A table that shows the new value before the server has agreed has to put the old one back when it disagrees — and only the host can write to its own rows, so onEditRollback receives the row as it was and restores it. The failed cell then offers an Undo (labels.undoEdit, localized in every bundled locale), and pressing it calls that handler. Omit onEditRollback and the message shows without an undo, which is right for a table that refetches instead.

A newer save supersedes an older one, so a slow rejection can never mark a value the reader has already replaced. A host that saves synchronously pays nothing: with nothing to wait for, there is no saving state to render.

Headless: useCellSaveState (CellSaveState, CellSaveStatus, FailedCellSave, UseCellSaveStateOptions); the controller carries saveStatus, saveFailure, canRollback, rollback and dismissFailure.

Cell editing commits each field as the reader leaves it, which is right for a spreadsheet and wrong for a form. A row whose fields constrain each other cannot be edited one cell at a time without passing through states that are invalid on the way — a start date after the end date it is about to replace. Row mode holds every field’s draft until the reader saves, then hands you one patch:

import { rowEditing } from "@adapttable/mantine/editing";
<DataTable
{...props}
features={[
rowEditing((row, patch) => {
// patch === { title: "Ship it", points: 8 } — only what changed
setRows((current) => applyPatch(current, row.id, patch));
}),
]}
/>;

Each row grows an Edit control; opening one turns every editable column of that row into its editor at once, seeded from the row. Save hands over one patch of only the fields that actually changed — an untouched row reports nothing at all — and Cancel throws the drafts away. Enter and Escape do the same from any field, because in row mode the unit is the row.

Only one row is open at a time: opening another closes the first. The same editors, the same parseValue, the same column-level editable predicate. The mobile card behaves identically — the fields open in the card and the three controls sit in its action area.

Both are one factory: rowEditing(onRowEdit) arms the mode and wires the patch channel. editing() is not required — a table that only wants row-level commits leaves it out, and its cells stay display-only until a row is opened.

A table that already has a pencil in its actions column does not want a second control beside it saying the same thing. Mark that action editsRow and it becomes the way in — the built-in Edit control stands down, and save and cancel still come from the open row:

import { DataTable } from "@adapttable/mantine";
import { rowEditing } from "@adapttable/mantine/editing";
import { rowActions } from "@adapttable/mantine/row-actions";
const actions = [
{ key: "edit", label: "Edit", icon: <PencilIcon />, editsRow: true },
{ key: "delete", label: "Delete", onClick: remove },
];
<DataTable {...props} features={[rowActions(actions), rowEditing(save)]} />;

Such an action writes nothing itself, so it carries no onClick, and it renders only where there is a form to open: a table without rowEditing drops it rather than showing a control that cannot do what it says. While its own row is open it steps aside, because that row is already showing save and cancel.

A row form is measured against the row it opened on, so a change arriving underneath is the same question a cell already asks — asked of every field that moved, and of no others. Each marks itself, shows the value that arrived, and offers Keep mine / Take theirs, exactly as a cell does; answering on any of them answers for the row, because the row moved as a whole. The form offers nothing to save until it is answered.

Fields the reader never touched are not a problem to solve: a save sends a patch of what the reader changed, so a value they left alone is never written back over the one that arrived.

A batch asks the same way. Every cell is a field there, so an untouched one already reads the row itself; a cell the reader changed marks itself and is answered on its own, and Save all waits until nothing is outstanding.

editConflictPolicy and onEditConflict decide it without asking, exactly as they do for a cell.

Parts: the cell’s own edit-cell-conflict, edit-cell-conflict-message, edit-cell-incoming, edit-cell-keep-mine, edit-cell-take-theirs, plus batch-edit-conflict where the batch bar says why it is not saving yet. Labels: labels.editConflict, labels.keepMine, labels.takeTheirs, labels.theirsValue.

Each kit draws its own pencil, check and cross, with labels.editRow, labels.saveRow and labels.cancel as both the accessible name and the hover title — an actions column is a narrow place, and three words per control crowd out the row. rowEditIcons changes that per control: a node is your own glyph, false asks for the label as text.

import { DataTable } from "@adapttable/mantine";
import { rowEditing } from "@adapttable/mantine/editing";
<DataTable
{...props}
features={[
rowEditing(save, { rowEditIcons: { save: <TickIcon />, cancel: false } }),
]}
/>;

Parts: row-edit-begin, row-edit-actions, row-edit-save, row-edit-cancel. Labels: labels.editRow, labels.saveRow, labels.cancel, all localized in every locale.

Headless: useRowEditing (RowEditingState, RowEditDrafts, UseRowEditingOptions), with RowEditCell (RowEditCellProps), rowEditControls (RowEditControlsOptions in, RowEditControls out) from @adapttable/react/adapter, RowEditIcons typing the glyph overrides, and rowEditConflict (RowEditConflict out) telling one row’s controls that an answer is outstanding. useEditConflict returns reconcileRow (ReconcileLiveRowEdit in) and isRowConflict for the row unit, and reconcileBatch (ReconcileLiveBatchEdit in) for a batch; CellConflictAsk is what one field’s notice needs, and EditConflictChange names each field that moved. Each adapter mounts RowEditActions (RowEditActionsProps) over RowEditActionsChrome, and resolves one row’s actions against its edit state with resolveRowEditTrigger (RowEditTrigger out) — which rewires an editsRow action to the form and reports whether the built-in control still draws itself.

A review pass — walking a list correcting values — wants one write at the end, not one per row. Compose batchEditing() and every editable cell becomes a field and holds every change until the reader saves them all:

import { batchEditing } from "@adapttable/mantine/editing";
<DataTable
{...props}
features={[
batchEditing((edits) => {
// edits === [{ row, rowId, patch }, …] — every pending row, once
return api.saveAll(
edits.map((edit) => ({ id: edit.rowId, ...edit.patch }))
);
}),
]}
/>;

The batchEditing handler is called once per save, which is what lets the whole batch be one request — and makes it atomic if your endpoint treats it that way. A bar appears as soon as something is pending, with the count, Save all and Cancel all; it is a live region, so the count is heard as well as seen. Cancel restores everything at once, because nothing was ever applied.

The count is rows, not cells: three changes across two rows read “2 unsaved rows”. A value typed back to what it was stops counting. Changed cells carry data-changed, so the reader can find their way back to what they touched.

Labels: pendingRows(count), saveAll, cancelAll — localized in every locale. Parts: batch-edit-cell, batch-edit-bar, batch-edit-count, batch-edit-save, batch-edit-cancel.

batchEditing is also its own entry, @adapttable/<kit>/batch-editing, for a table that wants only the batch mode.

Headless: useBatchEditing (BatchEditingState, BatchRowEdit, UseBatchEditingOptions), with BatchEditCell on @adapttable/react/adapter. Each adapter mounts BatchEditBar over BatchEditBarChrome.

Editing a value is one thing; changing which rows exist is another. Pass the handlers as the second argument of rowActions, and each one puts its own control on screen:

import { rowActions } from "@adapttable/mantine/row-actions";
<DataTable
{...props}
features={[
rowActions(undefined, {
onAddRow: () => setRows((rows) => [blankTask(), ...rows]),
onDuplicateRow: (row) =>
setRows((rows) => [{ ...row, id: nextId() }, ...rows]),
onDeleteRow: (row) =>
setRows((rows) => rows.filter((r) => r.id !== row.id)),
}),
]}
/>;

onAddRow puts an Add row button in the toolbar. onDuplicateRow and onDeleteRow put icon-only Duplicate row and Delete row on every row (the labels are the tooltip and accessible name), after your own row actions — the first argument of rowActions — so a delete stays last, where a destructive action belongs. They ride the actions column like any other row action: hideable and end-pinnable from the Columns menu, buttons on desktop and card buttons on mobile. The row actions page covers the column itself.

A delete asks first. It goes through the same confirmation dialog a rowActions entry with a confirm block uses — labels.deleteRow as the title, labels.deleteRowConfirm as the question, both translated in every bundled locale. Pass confirmDeleteRow: false when your own UI already asked, or when your delete is reversible.

The table asks; you do the rest. It holds no draft and stores no row. A row you add arrives through the source like every other row, which is what keeps it ordinary — editable, filterable, sortable, counted, grouped and virtualized from the moment it lands, with nothing about it special. It also means what a copy means is yours: which fields carry over, which reset, what id it gets.

For an in-memory table, insertRow, removeRow and applyRowPatches do the list work.

Nothing here needs editing(). Pair them and a reader adds a blank row and fills it in place; leave editing off and Add is simply a button that runs your handler.

Headless: useRowMutations (RowMutationHandlers, RowMutationsState) builds the same state a kit renders, and chrome.rowMutations carries it — canAdd plus addRow, with the duplicate and delete actions already folded into chrome.rowActions.

Changing which row sits where is the same one-way write: compose rowReorder and a grip appears. See row reordering. Compose rowPinning() and pin actions appear. See row pinning.

A table that looks identical before and after a save leaves the reader no way to tell what is still at risk. Compose dirtyIndicators() and a changed cell carries data-dirty until its value settles — and so does its row, so a long table can be scanned without hunting for the cell inside it.

import { dirtyIndicators, editing } from "@adapttable/mantine/editing";
<DataTable {...props} features={[editing(save), dirtyIndicators()]} />;

A mark clears when the save resolves, and stays when it fails: the value is still at risk until the reader undoes it or tries again. A rollback clears it too, since the value it belonged to is gone. Nothing clears on a timer — a mark that fades on its own says the change is safe when nobody checked.

Off by default, because a mark is a claim about what the server has agreed to and a table whose host never says would be guessing. For a table that confirms its own state another way — a refetch that agrees, a websocket echoing the value back — editing(commit, { onDirtyChange }) tracks unsaved edits on its own and hands them over on mount and whenever one moves: { count, confirm, confirmRow, confirmAll }, a count for an “unsaved changes” line and the functions that settle them. It draws no marks; add dirtyIndicators() for those, and both read the same set.

const [dirty, setDirty] = useState<DirtyEdits>();
<DataTable
{...props}
features={[editing(save, { onDirtyChange: setDirty })]}
/>;
// dirty?.count — "3 unsaved changes"; dirty?.confirmAll() after a refetch

Headless: useDirtyCells (DirtyCellState, UseDirtyCellsOptions) and rowIsDirty(editing, rowId) from @adapttable/react/adapter.

The editing engine is exported for custom adapters and fully custom tables. useCellEditing is the state machine (one active cell, a draft string, and the Enter / Escape / Tab keyboard flow); EditableCellGate is the reusable activation wrapper every built-in adapter renders (double-click / Enter / F2 to begin, with the cell value as the accessible name and the edit hint as its title).

useCellEditing, EditableCellGate and the MultiSelectEditor* exports come from @adapttable/react; isCellEditable, hasEditableColumns, parseCellEditValue, resolveCellEditor and resolveCommitValue from @adapttable/core; editorValidationProps from @adapttable/react/adapter.

Export Purpose
useCellEditing / CellEditingState The state machine hook and the state it returns.
EditableCellGate / EditableCellGateProps Activation wrapper: display content when idle, the kit’s editor while active.
EditableCellEditing The editing bundle adapters receive from the chrome (chrome.editing).
CellEditCommit / CellEditTarget A commit payload (row, key, value) and the active-cell address.
CellEditor / CellEditorOption The column editor descriptor and one option of a select editor.
CellEditKeyAction / CellEditKeyOutcome / CellEditNavigation Keyboard-flow vocabulary: what a key press means and where focus goes next.
EditableCellController / EditableCellEditorCtrl / EditableCellMode The controller handed to a custom editor: draft, commit/cancel, mode.
MultiSelectEditorChrome / MultiSelectEditorChromeProps / MultiSelectEditorSlots / MultiSelectEditorCheckboxProps Checkbox-group multi-select editor for kits whose select holds one value.
EditableColumnLike / isCellEditable / hasEditableColumns The minimal column shape editing reads, plus the two predicates the chrome uses.
parseCellEditValue / resolveCellEditor / normalizeEditorOptions Draft parsing (number editors yield number | null) and editor/option resolution.
EditValidationState / ValidationCheckResult Validation state: which cells carry a message, which are still checking, and what a check returned.
CellValidator / RowValidator / ValidationTarget The two validator signatures and the cell address a check is about.
resolveCommitValue The row, column and parsed value a commit resolves to — what a validator judges before the host sees it.
editorValidationProps The aria-invalid / aria-describedby / aria-busy a kit’s editor spreads while validation is in play.

A commit hands you a change; what you do with your data is yours. Refetching the page to reflect it costs a round trip and throws away the user’s scroll position, open rows, and sometimes their selection.

applyRowPatches applies changes to the rows you already hold. The same helper is how a realtime feed lands:

import { applyRowPatches, updateRow, removeRow } from "@adapttable/core";
import { editing } from "@adapttable/mantine/editing";
const [rows, setRows] = useState(initial);
const byId = (row: Person) => row.id;
<DataTable
data={rows}
columns={columns}
rowKey={byId}
features={[
editing((row, key, value) =>
setRows((current) =>
applyRowPatches(current, [updateRow(byId(row), { [key]: value })], byId)
)
),
]}
/>;

Four builders — insertRow, updateRow, upsertRow, removeRow — and a batch is just an array, applied in order, so a later patch acts on what an earlier one did.

Two guarantees make it safe to call on every commit:

  • Untouched rows keep their object identity. React reconciles them as unchanged, and per-row memos — a computed column’s cache, a memo’d cell — stay valid instead of recomputing for the whole page.
  • A patch that changes nothing returns the very same array. An update whose values already match, an upsert of the row already in place, a removal of an id that is not there, or an empty batch hands back the original reference, so the setState does not re-render.

Selection and expansion survive for the same reason: both are keyed by row id, and a patch never changes the id of a row it did not touch.

applyRowPatches is a pure function over an array — the table does not own your data and this does not make it start.

The patch shapes are exported for code that builds them dynamically: RowPatch is the union, with InsertPatch, UpdatePatch, UpsertPatch and RemovePatch as its members.

  • Works on desktop rows and mobile cards, LTR and RTL.
  • Custom Cell / accessor still render in display mode; the editor replaces them only while that cell is active.
  • Prefer updating your row list immutably in your editing() handler so React sees a new data / source identity.

See it live in the demo — double-click an editable cell (Person, Email, or Team) in the editing section.

Compose editHistory from @adapttable/<kit>/editing and edits can be taken back:

import { DataTable } from "@adapttable/mantine";
import { cellNavigation } from "@adapttable/mantine/cell-navigation";
import { editHistory, editing } from "@adapttable/mantine/editing";
<DataTable
data={rows}
rowKey={(row) => row.id}
features={[cellNavigation(), editHistory(), editing(commit)]}
columns={[{ key: "budget", header: "Budget", editable: true }]}
/>;

Ctrl/Cmd+Z undoes, Ctrl/Cmd+Shift+Z and Ctrl+Y redo. Both announce what moved — labels.editUndone, labels.editRedone, or labels.editNothingToUndo when the history is empty.

An undo does not rewrite your data, because the table never owned it. It commits the previous value back through your editing() handler, the same call the original edit made — so validation, a mutation, an optimistic update, a toast, whatever you wrapped around editing, all run on the way back exactly as they ran on the way out.

One gesture is one entry. A paste of two hundred cells undoes in a single press, as does a fill; an inline edit is a gesture of one. Fifty gestures are kept by default — pass { depth: 200 } to keep more.

The keys live on the grid, so they need cellNavigation(). For visible controls, compose undoRedoButtons() beside editHistory(): Undo and Redo sit in the toolbar and disable when there is nothing to take back. Without editHistory() they do not render. See toolbar and view controls.

import { DataTable } from "@adapttable/mantine";
import { cellNavigation } from "@adapttable/mantine/cell-navigation";
import {
editHistory,
editing,
undoRedoButtons,
} from "@adapttable/mantine/editing";
<DataTable
{...props}
features={[
cellNavigation(),
editHistory(),
undoRedoButtons(),
editing(commit),
]}
/>;

For your own buttons, editHistory({ onChange }) hands over the history on mount and whenever canUndo or canRedo changes — undo(), redo(), canUndo, canRedo and clear():

const [history, setHistory] = useState<EditHistoryHandle>();
<button disabled={!history?.canUndo} onClick={() => history?.undo()}>
Undo
</button>
<DataTable
{...props}
features={[editHistory({ onChange: setHistory }), editing(commit)]}
/>;

The functions are stable and always act on the table’s current history. Call clear() when you replace the data underneath, since a history of rows that no longer exist can only put back values nobody wants.