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