Undo & redo
Op-based, id-keyed 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 op-based and id-keyed, not a snapshot stack. Each entry is the ops 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 now 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 op 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, undo, redo, clear } for wiring your own toolbar
buttons; clear empties both stacks (e.g. after regenerating the dataset).
Ctrl/Cmd+Z, Ctrl/Cmd+Y, and
Ctrl/Cmd+Shift+Z work automatically.
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 takes data and setData, mirroring the
tuple of useState, so it composes with any state source:
import { useDataGridHistory } from "@/components/data-grid-history/data-grid-history";
const [data, setData] = useState(initialRows);
const { onDataChange, undo, redo, canUndo, canRedo } = useDataGridHistory({
data,
setData,
getRowId: (row) => row.id,
capacity: 100, // optional; caps the undo stack, default is generous
});
<DataGridProvider
data={data}
columns={columns}
getRowId={(row) => row.id}
onDataChange={onDataChange}
onUndo={undo}
onRedo={redo}
>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 id-keyed op batch for the change:
const { onDataChange, undo, redo, canUndo, canRedo, record } = useDataGridHistory({
data,
setData,
getRowId: (row) => row.id,
});
// the app applies its own change, then registers it as ONE undo step:
setData((rows) => [...rows, extraRow]);
record(
{
source: "app",
ops: [{ type: "insert", rowId: extraRow.id, row: extraRow, index: data.length }],
},
"Add row", // optional entry label
);Two `record`s, one caveat
The same name behaves differently per hook: useDataGridHistory.record is register-only (you
applied the change to your own array), while useDataGridState.history.record applies the batch
to the hook-owned data AND registers it. If you let the hook own the data, the plain
onDataChange(next, { source: "app", ops }) call already records the change — record() is
only needed when the consumer wrote the array itself first. The label is write-only for now:
it survives undo/redo with the entry, but there is no API to read stack entries back yet.
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
ops: a record()/insertRows batch of 10,000 ops is one entry, but it does hold 10,000 op
wrappers for the life of the stack.