gridcn

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).

Name
Role
Score
Silent Tiger
Viewer
4
Quick Lion
Viewer
89
Swift Wolf
Manager
80
Eager Lion
User
51
Eager Tiger
Viewer
83
Silent Wolf
User
15
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.

On this page