Undo and redo
Add undo and redo to a store-backed document by pushing snapshot() copies onto a capped history and restoring them with reconcile(), so unchanged items keep their identity and their DOM rows.
This recipe adds undo and redo to a small document held in useStore. It demonstrates snapshot() for taking history
entries, reconcile() with setStore for restoring them, and a capped history stack, and it proves that an item that
an undo does not change keeps its object and its DOM row.
Why snapshot and reconcile#
A history entry has to be a copy that later edits cannot change. snapshot(store) returns a
deep plain copy of the store's current data. It reads raw data without subscribing the running computation, so taking
one inside an event handler or an effect costs no dependency.
Restoring is the other half. setStore(snapshotValue) would write every field. setStore(reconcile(entry))
(reconcile) diffs the entry into the store: objects are merged key by key, unchanged
leaves are not written, and array items are matched by key (default "id"), so an item present in both the store
and the entry stays the same object and is updated in place. Rows rendered by a keyed For therefore keep their DOM
nodes, and only the fields that differ notify their readers. An item that the entry has and the store lacks is
adopted from the entry; an item the store has and the entry lacks is removed (without the merge option).
The history hook#
The hook keeps two stacks of snapshots, past and future, as signals holding immutable arrays. An edit pushes the
document as it was before the change and clears future. Undo moves the top of past to future, redo does the
reverse, and both restore through reconcile.
import { reconcile, snapshot, useSignal, useStore } from "@shadow-js/core";
export interface Item {
id: number;
text: string;
done: boolean;
}
export interface Doc {
title: string;
items: Item[];
}
/**
* Wraps `initial` in a store with undo and redo. The store adopts `initial` in place, so pass a
* fresh object. `limit` is the most undo steps kept; the oldest entry is dropped first.
*/
export function useDocumentHistory(initial: Doc, limit = 100) {
const [doc, setDoc] = useStore(initial);
// useSignal: the update builds a new array from the previous one, which a store draft does not allow.
const [past, setPast] = useSignal<readonly Doc[]>([]);
// useSignal: the update builds a new array from the previous one, which a store draft does not allow.
const [future, setFuture] = useSignal<readonly Doc[]>([]);
const edit = (change: (draft: Doc) => void): void => {
const before = snapshot(doc);
setDoc(change);
setPast((list) => [...list, before].slice(-limit));
setFuture([]);
};
/** Applies the newest entry of `from` and records the current state on `to`. */
const travel = (
from: () => readonly Doc[],
setFrom: (update: (list: readonly Doc[]) => readonly Doc[]) => void,
setTo: (update: (list: readonly Doc[]) => readonly Doc[]) => void,
): void => {
const target = from().at(-1);
if (target === undefined) return;
const current = snapshot(doc);
// The entry leaves the stack before it is applied: reconcile can adopt its objects into the
// store, and later edits would then change an entry that history still holds.
setFrom((list) => list.slice(0, -1));
setTo((list) => [...list, current].slice(-limit));
setDoc(reconcile(target));
};
return {
doc,
edit,
undo: (): void => travel(past, setPast, setFuture),
redo: (): void => travel(future, setFuture, setPast),
canUndo: (): boolean => past().length > 0,
canRedo: (): boolean => future().length > 0,
};
}
The hook has no DOM, so a plain run block can exercise it. With limit 2, the third edit pushes the oldest entry out,
and two undos are all that remain.
import { useDocumentHistory } from "./document-history";
const history = useDocumentHistory({ title: "Draft", items: [] }, 2);
const show = (label: string): void => {
console.log(
`${label}: ${history.doc.title} (undo ${history.canUndo()}, redo ${history.canRedo()})`,
);
};
for (const title of ["One", "Two", "Three"]) {
history.edit((draft) => {
draft.title = title;
});
}
show("edited");
history.undo();
history.undo();
show("undone twice");
history.undo();
show("third undo");
history.redo();
show("redone");
edited: Three (undo true, redo false)
undone twice: One (undo false, redo true)
third undo: One (undo false, redo true)
redone: Two (undo true, redo true)The editor#
The editor edits a title and a checklist. The title commits on the native change event (onchange, in lower
case: blur or Enter), not on every keystroke, so one rename is one history entry. React's onChange fires on every
keystroke, which is the wrong event for this. The keyboard shortcuts ignore events from the title field, where the browser's own text
undo applies.
import { For } from "@shadow-js/web";
import { type Doc, useDocumentHistory } from "./document-history";
export interface EditorProps {
initial: Doc;
limit?: number;
}
export function Editor(props: EditorProps) {
const { doc, edit, undo, redo, canUndo, canRedo } = useDocumentHistory(props.initial, props.limit);
const addItem = (): void => {
edit((draft) => {
const id = draft.items.reduce((max, item) => Math.max(max, item.id), 0) + 1;
draft.items.push({ id, text: `Item ${id}`, done: false });
});
};
const toggle = (id: number): void => {
edit((draft) => {
const item = draft.items.find((candidate) => candidate.id === id);
if (item !== undefined) item.done = !item.done;
});
};
const remove = (id: number): void => {
edit((draft) => {
const index = draft.items.findIndex((item) => item.id === id);
if (index !== -1) draft.items.splice(index, 1);
});
};
const onKeyDown = (event: KeyboardEvent): void => {
if (!event.ctrlKey && !event.metaKey) return;
if (event.target instanceof HTMLInputElement && event.target.type === "text") return;
const key = event.key.toLowerCase();
if (key === "z" || key === "y") {
event.preventDefault();
if (key === "y" || event.shiftKey) redo();
else undo();
}
};
return (
<div onKeyDown={onKeyDown}>
<input
aria-label="Title"
value={doc.title}
onchange={(event) => {
const title = event.currentTarget.value;
if (title !== doc.title) {
edit((draft) => {
draft.title = title;
});
}
}}
/>
<div role="toolbar" aria-label="History">
<button type="button" disabled={!canUndo()} onClick={() => undo()}>
Undo
</button>
<button type="button" disabled={!canRedo()} onClick={() => redo()}>
Redo
</button>
<button type="button" onClick={addItem}>
Add item
</button>
</div>
<ul aria-label="Items">
<For each={doc.items} key={(item) => item.id}>
{(item) => (
<li>
<label>
<input type="checkbox" checked={item().done} onChange={() => toggle(item().id)} />
{item().text}
</label>
<button type="button" aria-label={`Delete ${item().text}`} onClick={() => remove(item().id)}>
Delete
</button>
</li>
)}
</For>
</ul>
</div>
);
}
remove splices the draft in place. On the way back, reconcile matches the surviving items by id, so they keep their
identity in both directions, and only the removed item is created again.
Related: snapshot, reconcile,
ReconcileOptions, useStore,
SetStore, unwrap, renderHook.