Undo / redo

There is no built-in undo/redo stack inside <SvGrid>. The grid emits no per-edit event for an external stack to subscribe to, so undo/redo has to be built on top of the data-snapshot approach.

Open the live example: State maintenance (Rows & Cells)

Pattern - snapshot stack

The examples on this page run against these rows:

<script lang="ts">
  import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'

  type Person = {
    id: number
    name: string
    department: string
    city: string
    age: number
    salary: number
  }

  const people: Person[] = [
    { id: 1, name: 'Ada Lovelace',   department: 'Engineering', city: 'London',   age: 36, salary: 142000 },
    { id: 2, name: 'Grace Hopper',   department: 'Engineering', city: 'New York', age: 45, salary: 168000 },
    { id: 3, name: 'Linus Torvalds', department: 'Platform',    city: 'Portland', age: 54, salary: 155000 },
    { id: 4, name: 'Radia Perlman',  department: 'Networking',  city: 'Seattle',  age: 49, salary: 161000 },
    { id: 5, name: 'Barbara Liskov', department: 'Platform',    city: 'Boston',   age: 52, salary: 172000 },
  ]

  const columns: GridColumns<Person> = [
    { field: 'name',       header: 'Name',       width: 190, editorType: 'text' },
    { field: 'department', header: 'Department', width: 150, editorType: 'text' },
    { field: 'city',       header: 'City',       width: 130, editorType: 'text' },
    { field: 'age',        header: 'Age',        width: 80,  editorType: 'number' },
    { field: 'salary',     header: 'Salary',     width: 130, editorType: 'number', format: { type: 'currency', currency: 'USD' } },
  ]
</script>
<script lang="ts">
  type Snapshot = Person[]

  let rows = $state<Person[]>(initial)
  let past = $state<Snapshot[]>([])
  let future = $state<Snapshot[]>([])
  const LIMIT = 50

  // Whenever rows change because of an edit, push the previous state onto `past`.
  let last = JSON.stringify(rows)
  $effect(() => {
    const next = JSON.stringify(rows)
    if (next === last) return
    past = [...past.slice(-LIMIT), JSON.parse(last)]
    future = []
    last = next
  })

  function undo() {
    const prev = past.at(-1)
    if (!prev) return
    future = [rows.map((r) => ({ ...r })), ...future].slice(0, LIMIT)
    rows = prev.map((r) => ({ ...r }))
    past = past.slice(0, -1)
    last = JSON.stringify(rows)
  }

  function redo() {
    const next = future[0]
    if (!next) return
    past = [...past, rows.map((r) => ({ ...r }))].slice(-LIMIT)
    rows = next.map((r) => ({ ...r }))
    future = future.slice(1)
    last = JSON.stringify(rows)
  }
</script>

<svelte:window onkeydown={(e) => {
  const meta = e.ctrlKey || e.metaKey
  if (meta && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo() }
  if (meta && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) { e.preventDefault(); redo() }
}} />

<SvGrid data={rows} {columns} features={features} enableInlineEditing />

Why JSON snapshots

Without a per-edit event from the grid, the safest store of "previous state" is a deep copy of the row array. For a 5,000-row grid the snapshot is a few hundred KB - fine. For a 100,000-row grid, switch to a row-diff stack:

type Diff = { rowId: string; column: string; before: unknown; after: unknown }

…and apply it during undo/redo. The per-edit event needed to compute that cleanly is on the gap list.

Tracked at

Missing features - first-class undo stack with onCellValueChange to drive it.

More examples

Undo / redo (Ctrl+Z)

api.undo() / api.redo() + Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z. 200-action bounded history (an action is one Ctrl+Z, however many cells it wrote, so a paste or a sort of thousands of cells stays whole); clearHistory after a successful save resets the baseline.

A grid that shows one of several data sets in turn (a workbook's sheets, a list of records opened one at a time) has a question the history cannot answer on its own: which data set does the next Ctrl+Z belong to? The rows of the second set carry the same ids as the first, so a step recorded on one would land on the other. api.setHistoryTag(tag) marks every step recorded from then on with a value of the consumer's choosing, kept as given, and api.peekUndo() / api.peekRedo() report the tag of the step the next undo or redo would take (or null when there is none). The spreadsheet shell tags steps with the sheet, and when the tag is not the sheet showing it switches first, then lets the grid apply the step, which is what Excel does.

Open the live example: Undo / redo (Ctrl+Z) (Editing)

Try it

Edit a few cells, then step backwards. canUndo and canRedo are what the buttons should read - a disabled button is better than one that silently does nothing.

<script lang="ts">
  let api = $state<SvGridApi<{}, Person> | null>(null)
  let version = $state(0)

  // The api's undo state is not reactive on its own; bumping a counter after
  // every edit is what re-reads canUndo/canRedo for the button labels.
  const touch = () => (version += 1)
</script>

<div>
  <button type="button" disabled={!(version >= 0 && api?.canUndo())} onclick={() => { api?.undo(); touch() }}>
    Undo
  </button>
  <button type="button" disabled={!(version >= 0 && api?.canRedo())} onclick={() => { api?.redo(); touch() }}>
    Redo
  </button>
</div>

<SvGrid
  data={people}
  {columns}
  editable
  onCellValueChange={touch}
  onApiReady={(next) => (api = next)}
/>

See also

Live examples

  • State maintenance - Capture / restore the grid's sort, filters, visibility, widths, page, selection. Undo / redo history, named bookmarks, JSON import/export, debounced localStorage auto-save.
  • Undo / redo (Ctrl+Z) - `api.undo()` / `api.redo()` + Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z. 200-step bounded history; clearHistory after a successful save resets the baseline.
  • Inline editing - Typed editors (text/number/checkbox/date) with dirty tracking + save.