Skip to main content

Editing and clipboard

Editable cells support typed built-in editors, framework components, parsing, navigation after commit, history, and spreadsheet-style clipboard workflows.

Loading interactive example…
React
const columns: ReactColDef[] = [{
key: "status",
label: "Status",
editable: true,
cellEditor: "select",
cellEditorParams: { values: ["On track", "At risk", "Blocked"] },
}];

<Grid
rowData={rows}
columnDefs={columns}
editTrigger="doubleClick"
undoLimit={50}
highlightActiveCell
onCellValueChanged={saveChange}
/>

Built-in editors​

Choose text, number, date, boolean, select, or textarea. If cellEditor is omitted, the grid chooses from ColumnType. valueParser converts raw text before writing; typed editors can report already-parsed values.

const columnDefs: ColDef[] = [
{ key: "name", label: "Name", editable: true, cellEditor: "text" },
{ key: "quantity", label: "Quantity", editable: true, cellEditor: "number" },
{ key: "due", label: "Due", editable: true, cellEditor: "date" },
{ key: "active", label: "Active", editable: true, cellEditor: "boolean" },
{
key: "status",
label: "Status",
editable: true,
cellEditor: "select",
cellEditorParams: { values: ["Open", "Pending", "Closed"] },
},
{ key: "notes", label: "Notes", editable: true, cellEditor: "textarea" },
];

Without a valueParser, text is parsed by the column's type — and this applies to every path that carries text, not just the editors: paste, the fill handle, Delete/Backspace, and a string given to setCellValue.

  • number / currency: whatever Number() accepts, plus the column's own display form: a currency column reads its formatter's output ($1,234.00, -1.234,56 € under formatterOptions: { locale: "de-DE", currency: "EUR" }, accounting parentheses for negatives), and a number column that sets formatterOptions.locale reads that locale's grouping and decimal separators — so under de-DE, 1.234,5 is 1234.5 and 1,234 is 1.234, never 1234. Separators come from the locale, and grouping must sit where the locale puts it. Blank is null.
  • date: ISO 8601 text (2026-01-02, optionally with a time), or text in the column's display format (formatterOptions.format, e.g. DD/MM/YYYY or MMM D, YYYY), stored in the shape the cell already holds — a Date stays a Date, ISO text stays text, an epoch number stays a number. Other orders are refused: 01/31/2026 is not a date in a DD/MM/YYYY column.
  • boolean: true/false, yes/no, y/n, 1/0; blank is null.
  • string: verbatim.

So copying a cell and pasting it into a column of the same type and format writes the same value back. Text the type cannot hold — "abc" into a number column — is refused: the cell keeps its value, nothing enters undo history, and no cellValueChanged fires, exactly as if onBeforeCellCommit had vetoed it. An editor commit reports it as editingChanged with state: "rejected"; in a paste or fill the cell is simply skipped and the rest of the block keeps its alignment. A custom valueParser can refuse the same way by returning the exported REJECT.

A formatter/parser pair round-trips a display format through editing:

const column = {
key: "percent",
label: "Percent",
editable: true,
valueParser: ({ value }) => Number(value.replace("%", "")) / 100,
valueFormatter: ({ value }) => `${Math.round(value * 100)}%`,
} satisfies ColDef;

Number editor​

The number editor is a free-text input with numeric augmentation — not a native <input type="number"> — so formatted values are editable. ArrowUp/ArrowDown step by cellEditorParams.step (default 1), clamped to min/max and quantized to the step's decimal precision; mobile keyboards get a decimal keypad via inputmode.

  • Without a valueParser the raw text is parsed as a plain number on commit — clamped to min/max, null when blank or invalid.
  • With a valueParser the editor seeds with the column's formatted display value and commits the raw string through your parser, so formats the cell renders — 1,234.5, 45%, $50 — round-trip through editing. Arrow stepping goes parse → ±step → format (12.5% steps to 13%), and min/ max only bound stepping: commit-time validation belongs to the parser.

Date editor​

The date editor is the native <input type="date"> calendar by default, opened on the cell's local calendar date (min/max bound it). It commits through the column's built-in date parsing, so the stored shape survives the edit — a Date column keeps Dates, an epoch column numbers, an ISO-text column yyyy-mm-dd text — and a cleared input commits null. With a valueParser it switches to typed-format entry, the same mechanism as the number editor: a text input seeded with the column's formatted display value that commits the raw string through your parser — so a MM/DD/YYYY formatter/parser pair lets users type 12/25/2026 directly. ArrowUp/ArrowDown step the date by cellEditorParams.step days (default 1), round-tripping parse → ±step days → format; a blank input steps from the cell's original value. Validation belongs to the parser.

Editing starts with double-click, F2, Enter, or a printable character. Configure single-click editing, API-only editing, commit-on-blur, and Enter/Tab navigation with grid options:

const options = {
editTrigger: "singleClick",
suppressTypeToEdit: true,
moveAfterEdit: true,
commitOnBlur: true,
reevaluateOnEdit: true,
undoLimit: 50,
} satisfies GridOptions;

Use editTrigger: "none" with suppressKeyboardEdit: true for API-only editing:

const cell = { rowId: "order-1", colId: "quantity" };

api.startEditingCell(cell);
api.stopEditing("42");
api.cancelEditing();
api.setCellValue(cell, "43");

Custom editors​

const uppercaseEditor = (params: ICellEditorParams) => {
const input = document.createElement("input");
input.value = String(params.value ?? "").toUpperCase();
return {
init: () => {},
getGui: () => input,
getValue: () => input.value.toUpperCase(),
focus: () => input.focus(),
};
};

const column = {
key: "code",
label: "Code",
editable: true,
cellEditor: uppercaseEditor,
} satisfies ColDef;

The React and Angular bindings accept framework components in the same cellEditor slot, with editor handles for value, focus, and parse state.

Validating commits: onBeforeCellCommit​

A synchronous pre-commit hook runs before any user-initiated write — editor commits, setCellValue, and each cell of a paste/cut/clear batch — after the column's valueParser, so it sees { rowId, colId, data, value, oldValue, source } with value in its stored form. Return:

  • REJECT (exported sentinel) to veto the write: the cell keeps its old value, nothing enters undo history, no cellValueChanged fires, and an editor commit emits editingChanged { state: "rejected" } instead;
  • a value to store it in place of the proposed one (coerce/clamp);
  • undefined (or nothing) to accept the proposed value — return null when you mean "store an empty value".

Undo/redo replay already-accepted values and skip the hook. Server-validated edits that need an async veto should combine the sync hook (for what can be checked locally) with readOnlyEdit below — commit nothing, validate on the server, write back through your store.

Edits write into your row objects​

Committed edits are written in place into the exact row objects you passed as rowData — the grid does not copy on ingest or on write. If your application store assumes immutability (Redux, NgRx, signals with reference equality), hand the grid copies of your rows, or treat the grid's data as grid-owned and sync changes back through onCellValueChanged.

readOnlyEdit: the application owns the write​

Set readOnlyEdit: true and the grid never touches your row objects: every write path still runs the full pipeline — valueParser, onBeforeCellCommit, editingChanged, cellValueChanged — but the value is not written and nothing enters undo history (there is nothing of the grid's to undo). Treat cellValueChanged as the edit request: apply it to your store and pass the new data back via rowData or applyTransaction. This is the intended mode for immutable stores, replacing hand-rolled commit-then-rollback flows.

Reacting to value changes​

onCellValueChanged (and the cellValueChanged event) covers every write path — editor commits, setCellValue, paste, cut, clear, and undo/redo — with { rowId, colId, value, oldValue, source }. It fires once per cell whose stored value actually changes: committing the value a cell already holds emits nothing (equality is SameValueZero plus Dates compared by instant — the same rule is exported as valuesAreSame). value/oldValue are the stored (parsed) forms, and source says what wrote the cell ("edit", "paste", "cut", "clear", "fill", "undo", "redo"), so diff consumers (dirty tracking, server sync, analytics) need no shadow copy of the data. Two exceptions: under readOnlyEdit the grid writes nothing and cannot compare, so it fires for every accepted write (the value it reports is what the grid would have written); and undo/redo report the recorded transition, which can be a no-op if the row data was mutated externally after the step was recorded. Don't filter events by comparing value to oldValue yourself — on a valueGetter column the two can be equal even though the stored field moved; the event firing is the change signal. The editingChanged event still fires for every commit (changed or not), additionally carries oldValue on its committed state, and reports onBeforeCellCommit vetoes of editor commits as state: "rejected". colId in these payloads is the public ColDef.colId; the internal instance id rides on colInstanceId.

Clipboard and history​

Copy serializes the selection as TSV, each cell as it is displayed (its formatted text); Ctrl/Cmd+C, api.copySelection() and the body menu's Copy / Copy with Headers share one serializer. Group rows drop out of a selection that also holds leaf rows unless groupRowsSelectable is on; a selection of group rows alone copies them. Cut clears editable destinations, paste runs each destination's parser — its valueParser, or the built-in one for its type — skipping cells that refuse the text, and Delete/Backspace clears contents (typed columns clear to null). A pasted block tiles across a larger selected range when its dimensions divide evenly.

Ctrl/Cmd+V pastes through the browser's own paste event on the focused grid, which carries the clipboard text without asking for clipboard permission, so it works in every browser and in insecure (http:) contexts. A paste while a cell editor or another input inside the grid has focus belongs to that input. api.paste() and the body menu's Paste have no keyboard event to read from: they use navigator.clipboard.readText(), which some browsers refuse or do not offer (Firefox without the async clipboard, insecure contexts, a denied permission). In that case nothing is pasted and the grid logs one warning pointing at Ctrl/Cmd+V.

Every edit, paste, cut, or batch clear that changes at least one stored value is one undo step — a batch's entry holds only the cells that actually changed, and a commit of an unchanged value records nothing (so Ctrl+Z always produces a visible change). Set undoLimit: 0 to disable history.

api.undo() / api.redo() traverse it, and historyChanged (or the onHistoryChanged option) fires whenever the stacks move, carrying { reason, canUndo, canRedo, undoDepth, redoDepth } — bind toolbar buttons to it instead of polling. reason is "commit", "undo", "redo", or "clear". Replacing rowData clears history (its entries reference rows by id) and reports reason: "clear". getHistoryState() reads the same snapshot on demand, for the toolbar's first render.

Fill handle​

The selection carries a small square on its bottom-right corner. Drag it down, up, right, or left — one direction per drag, the one the pointer travels further along — and the cells it passes show a dashed outline; release to write them as one undo step. The selection then covers source and result together, with the active cell still on a cell you chose.

What gets written follows the spreadsheet rules, decided per line (a column when filling vertically, a row when horizontally):

  • Two or more numbers continue their linear trend: 10, 20 → 30, 40, and 1, 2, 6 → 8, 10.5, 13.
  • A lone date steps one calendar day; a run of dates keeps its interval, across month ends and DST changes. Date text in the YYYY-MM-DD shape does the same and stays text.
  • Text ending in a number counts up: Item 1 → Item 2, Item 3; Item 1, Item 3 → Item 5, Item 7. The last run of digits is the counter, the rest must match across the cells, and zero padding is kept (v08 → v09, v10).
  • Weekday and month names cycle in order, long or short, keeping the casing you typed: Monday → Tuesday, Nov, Dec → Jan, Feb, MON → TUE. English names are always recognised, plus those of the browser's language. Quarters cycle too: Q3 → Q4, Q1 (Qtr1 and Quarter 1 likewise).
  • Your own sequences cycle the same way through fillHandle: { lists }, like a spreadsheet's custom lists: with lists: [["Low", "Medium", "High"]], Low → Medium, High, Low, and low → medium (an entry spelled as listed takes the list's spellings, so VP stays VP). Your lists are tried before the built-in names and the counter, so one can replace the weekday order or make Item 1 cycle.
  • Everything else — plain text, booleans, a lone number, text that is only a number or a date with a time, a mixed line — repeats.
  • Hold Ctrl/Cmd when you release to flip the choice: a lone number counts up by one, and a series — numbers, dates, or text patterns — repeats instead.

Ctrl/Cmd+D fills the selection down from its first row and Ctrl/Cmd+R fills it right from its first column; a single cell takes the value above or to its left. Both are copies, never series, and leave the selection where it is. The body context menu offers the same two commands as Fill down and Fill right whenever they would write something, and api.fillDown() / api.fillRight() run them from outside the grid; api.canFillDown() / api.canFillRight() answer the menu's "would write something" test, for a toolbar button's enabled state (re-read them on selectionChanged and cellsChanged).

Right after a drag or double-click fill, the context menu on any cell the fill covered offers the other choice: Copy cells instead where the fill stepped a series, or Fill series instead where a line that could step was copied (a lone number, for instance). Each redo is its own undo step and leaves the selection where it is; the offer lasts until another write, or any change to the rows or columns under the fill (a sort, filter, or column move), retires it.

Double-click the handle to fill the selection down to the end of the data beside it, the way a spreadsheet completes a column. The column to the left of the selection is the guide — the one to its right when the left is blank in the row just below — and the fill runs through the guide's unbroken run of non-blank rows. A group row ends the run, so a fill inside one group stays there. Copy or series is decided as for a drag, and Ctrl/Cmd flips it the same way.

fillHandle: true // default: series where possible, any direction
fillHandle: { mode: "copy" } // always repeat; Ctrl/Cmd does nothing
fillHandle: { direction: "y" } // rows only (Ctrl/Cmd+R is left to the browser)
fillHandle: false

Filling down or up stays inside one column, so values move as stored and the valueParser never runs. Filling across columns, a value reaches a column of the same type as stored too; into a differently typed column its displayed text goes through that column's parser, exactly as a paste would — so a name dragged over a number column is refused and the numbers stay. Every cell still passes onBeforeCellCommit, and cellValueChanged reports source: "fill". Non-editable cells, group rows, rows not yet loaded, and refused cells are skipped without disturbing the pattern.

Series the grid cannot know — fiscal periods, business days, ordered enumerations, codes with rules, values derived from the row — belong to fillOperation. It is called once per target cell on every fill path (drag, double-click, Ctrl/Cmd+D / Ctrl/Cmd+R, the menu items and redo) with the line's source values, the cell's position in the pattern, and the value the grid would write on its own. Return { value } to write that value as the cell's stored value, { skipCell: true } to leave the cell untouched while it keeps its place in the pattern, or nothing to keep the grid's value:

// Dates in the "due" column step by business days; everything else is the grid's own.
fillOperation: ({ colId, values, index, lineMode }) => {
if (colId !== "due" || lineMode !== "series") return; // keep the grid's value
const last = values[values.length - 1] as Date;
const steps = index - (values.length - 1); // 1 for the first cell past the source
const date = new Date(last);
for (let left = Math.abs(steps); left > 0;) {
date.setDate(date.getDate() + Math.sign(steps)); // negative when filling upward
if (date.getDay() !== 0 && date.getDay() !== 6) left--;
}
return { value: date };
},

A returned value does not run the column's parser — the callback already knows the target column — but still passes onBeforeCellCommit and the no-op check; null is the blank. The grid also consults the callback when deciding whether a fill would write anything (to show or hide the menu's fill items), so keep it free of side effects.

The handle needs cellSelection: true and rangeSelection, appears only while the selection covers at least one editable column, and never on a selection that reaches into a pinned band.

Programmatic writes and history​

A bulk programmatic update should undo as one user action, not N:

// One undo step for the whole batch.
api.setCellValues([
{ cell: { rowId: "r1", colId: "salary" }, value: 120000 },
{ cell: { rowId: "r2", colId: "salary" }, value: 95000 },
]);

// Same, for writes spread across several calls or helpers.
api.withUndoGroup(() => {
for (const row of affected) api.setCellValue({ rowId: row.id, colId: "band" }, row.band);
});

Changes the user did not make — server pushes, op-protocol reconciliation, recomputed derived columns — should not land on their undo stack at all:

// Writes apply and emit their normal events; the undo stack is left untouched.
api.withoutUndoHistory(() => api.setCellValues(incomingOps));

Both scopes are synchronous: writes made after the callback returns (in a promise or timer it schedules) fall outside the scope. Nested scopes inherit the outermost mode, so a withUndoGroup helper called inside withoutUndoHistory stays suppressed. applyTransaction never enters undo history and needs neither.