gridcn1.2.2

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

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 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 getRowId must tolerate holes. The grid runs getRowId on every element of the sparse array, including the undefined holes. The plain (row) => row.id form throws on a hole. The lazy hook's own getRowId option only sees loaded rows, and lazy.gridProps.getRowId is already wrapped to answer a placeholder id for a hole. Pass the plain form to the hook and lazy.gridProps.getRowId to 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 canUndo can 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 as datasetKey, so the stack clears itself on the next render. If your dataset has no stable identity, call clear() in the same handler.

undo also dev-warns once when it runs over a sparse array.

On this page