[]previous and next page

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.

For agentsUpdated

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.

src/document-history.tsts
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.

src/demo.tsts
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");
Expected output
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.

src/editor.tsxtsx
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.