Undo & redo
Operation-based, row-id-based history that survives sort and filter.
Undo and redo live in the separate data-grid-history add-on.
Edit a cell, then undo/redo with the buttons below or Ctrl+Z / Ctrl+Y (Cmd on Mac).
pnpm dlx shadcn@latest add @gridcn/data-grid-history
How it works
History is operation-based and row-id-based, not a snapshot stack. Each entry is the operations batch from one
onDataChange call (an edit, paste, fill, or delete-range), addressed by row id rather than
array index. So undo and redo keep working correctly across sorts and filters. Undoing an edit
made before a sort still finds and reverts the right row, wherever it sits in the view.
A vanished row id is skipped, not an error
If a row's id has since disappeared, for example because an external data refresh removed it, history skips that operation on undo and redo rather than raising an error. This is documented behavior, not a silent surprise.
Quick start: useDataGridState
How this differs from defaultData
useDataGridState owns the array in your component and passes it as the CONTROLLED data prop,
re-feeding the grid every render. Undo and redo work precisely because of that echo. It is NOT
the core's defaultData semantics (set once, then grid-owned with later props ignored). Reach
for it only when you actually want history. Otherwise, defaultData alone means one less add-on
to install. See the three-shapes table in
Events & state for the
controlled/uncontrolled/events distinction.
The fastest path wires useState, history, and onDataChange into one spreadable object:
import { useDataGridState } from "@/components/data-grid-history/data-grid-history";
const grid = useDataGridState(initialRows, { getRowId: (row) => row.id });
<DataGridProvider {...grid} columns={columns}>
<DataGridRoot>
<DataGridHeader />
<DataGridBody />
</DataGridRoot>
</DataGridProvider>;grid.history exposes { canUndo, canRedo, historySize, undo, redo, clear, record } for wiring
your own toolbar buttons. historySize counts the entries on the undo stack. clear empties
both stacks; for a dataset change, pass datasetKey instead of calling clear by hand (see
Dataset changes). record applies a programmatic change and registers it as
one entry (see Programmatic changes).
Ctrl/Cmd+Z, Ctrl/Cmd+Y, and
Ctrl/Cmd+Shift+Z work automatically while focus is in
the grid. The history plugin provides the undo/redo commands behind those keys.
With the opt-in DataGridGlobalShortcuts component, undo and
redo also work while focus is on a custom toolbar or anywhere else on the page.
In a readOnly grid, the keys do nothing. Your own toolbar buttons call undo and redo
directly. Those are also no-ops on a readOnly grid, so bind their disabled to the grid's
readOnly flag (see Read-only grids).
Composable: useDataGridHistory
When you already own the data array through your own state, not the internal useState of
useDataGridState, wire the history hook directly. It returns one plugin. The grid's store is
the write target, and your controlled-data echo (your onDataChange calling your own state
setter) stays exactly where it always was:
import { useState } from "react";
import { useDataGridHistory } from "@/components/data-grid-history/data-grid-history";
const [data, setData] = useState<readonly Order[]>(initialRows);
const history = useDataGridHistory({
capacity: 100, // optional; caps the undo stack, default is generous
});
<DataGridProvider
data={data}
columns={columns}
getRowId={(row) => row.id}
onDataChange={setData}
plugins={[history.plugin]}
>The plugin's observer records every change the grid applies (after your own onDataChange
runs). The plugin's undo/redo commands back the keyboard bindings. history.undo,
history.redo, history.canUndo, and history.canRedo are the imperative and toolbar-facing
forms of the same history.
Programmatic changes: record
Changes your own app makes (setting the row count, adding a column, rolling back a batch save)
are not gestures, so they never reach onDataChange. Register them as a single undo entry with
record, passing the row-id-based operation batch for the change. record applies the batch to the grid
as one write AND registers it as one entry:
const history = useDataGridHistory();
// one call: the change lands in the grid and registers as ONE undo step
history.record(
{
source: "app",
ops: [{ type: "insert", rowId: extraRow.id, row: extraRow, index: data.length }],
},
"Add row", // optional entry label
);Do not write the array yourself first
record applies the change through the grid's commitChange. If you also apply it to your
own array, the grid's write and your state land out of order and the echo re-feeds a stale
array. Call record alone, and let the grid's onDataChange echo update your state. label
is write-only. It survives undo/redo with the entry, and there is no API to read stack entries
back.
Which changes are recorded
History records the changes a user made on purpose: edit, paste, fill, delete, row-operation,
import, replace, and app (see the source field in
Events & state). It never records
history (an undo or redo itself). It does not record stream by default, so a live feed of
updateCells batches never fills the stack or gets undone by accident. Pass recordSources to
either hook to change the list:
const history = useDataGridHistory({ recordSources: ["edit", "paste", "stream"] });An undo or redo reaches onDataChange with source: "history". If onDataChange saves to a
server, that handler sees the undo as a normal change and saves it.
Rolling back a failed save
An onDataChange handler that saves to a server sees undo and redo as changes with
source: "history". When a save fails, call rollback(change) with the change the handler
received:
const history = useDataGridHistory<Row>();
const onDataChange = async (_next: readonly Row[], change: DataChange<Row>) => {
if (change.source === "rollback") return; // the server never saw the failed change
try {
await api.save(change);
} catch {
history.rollback(change);
}
};rollback applies the inverse change with source: "rollback" and removes the entry from the
undo stack, so no dead Ctrl+Z step remains. Neither "history" nor
"rollback" changes are recorded. createHistory exposes the same removal as
discard(change).
Capped stack
Pass capacity to either hook to bound memory on long editing sessions. The oldest entries drop
once the stack exceeds it. Omit capacity for a generous default. capacity counts ENTRIES, not
operations: a record()/insertRows batch of 10k operations is one entry.
Dataset changes
Pass the identity of your dataset as datasetKey to either hook. When the key changes between
renders, the hook clears both stacks. That keeps undo from reinserting rows of the old dataset
after you swap in a new one:
const [datasetKey, setDatasetKey] = useState(0);
const grid = useDataGridState(initialRows, {
getRowId: (row) => row.id,
datasetKey,
});
// swap the dataset; the stack clears itself on the next render
setDatasetKey((key) => key + 1);A dataset id or a version counter both work. The key only needs to differ between datasets.
Use clear() for a reset with no key, for example a "reset to defaults" button.
Read-only grids
A readOnly grid refuses writes at the store. History follows that rule end to end: nothing new
records, and undo and redo are no-ops that keep their entries (popping an unapplied entry
would lose it). Entries recorded before the grid turned read-only keep canUndo true while
undo does nothing, so gate your toolbar buttons on the grid's readOnly flag as well:
<DataGrid {...grid} columns={columns} readOnly={readOnly} />
<button disabled={readOnly || !grid.history.canUndo} onClick={grid.history.undo}>
Undo
</button>The keyboard bindings are gated the same way: on a readOnly grid, mod+Z and mod+Y do nothing.
With data-grid-lazy
useDataGridLazyRows owns the data array. That array is sparse: loaded indices hold rows, and
unloaded indices hold undefined holes. Pass the lazy hook's own onDataChange echo as the
grid's onDataChange, and the history plugin as a plugins entry. Every grid write (and
history's own undo/redo writes) flows through the grid's single write path, so the lazy hook
sees every next array through that one echo:
const lazy = useDataGridLazyRows<Order>({ total, fetchRows, getRowId });
const history = useDataGridHistory({ datasetKey });
<DataGrid
data={lazy.gridProps.data}
columns={columns}
getRowId={lazy.gridProps.getRowId}
onDataChange={lazy.onDataChange}
plugins={[history.plugin]}
/>The grid applies a change, emits it, and the lazy hook merges the next array back into its sparse rows. The history observer records the change from the same emission.
Three rules make the combination safe:
- The grid's
getRowIdmust tolerate holes. The grid runsgetRowIdon every element of the sparse array, including theundefinedholes. The plain(row) => row.idform throws on a hole. The lazy hook's owngetRowIdoption only sees loaded rows, andlazy.gridProps.getRowIdis already wrapped to answer a placeholder id for a hole. Pass the plain form to the hook andlazy.gridProps.getRowIdto the grid, as in the example. - Evicted rows are silent no-ops. An entry whose rows were evicted or reset out skips those
operations and keeps the rest, so
canUndocan stay true on an entry that changes nothing. This is the same rule as the vanished row id skip above. - Clear the stack when the dataset changes.
lazy.reset()and a dataset swap drop the loaded rows the stack references. Pass the dataset's identity asdatasetKey, so the stack clears itself on the next render. If your dataset has no stable identity, callclear()in the same handler.
undo also dev-warns once when it runs over a sparse array.
Related
- Global shortcuts: undo and redo while focus is outside the grid
- Keybindings: the default-keymap undo and redo bindings