Undo & redo

Opt into a per-form history chain with one option. Every value mutation records a position; the namespace exposes undo() / redo() / clear() and the reactive flags that gate your UI.

Category
Module
Opt in
{ "useForm({ history": "true })", "kind": "code" }
Default depth
128 positions
Namespace
form.history

Type into any field, append a few tags, then hit ⌘Z / ⌘⇧Z (or click the buttons) to walk the chain. canUndo and canRedo gate the buttons reactively; clear() reseeds the chain at the current state, the move you'd make after a "Save successful" milestone.

Undo & Redo Demo Open in playground
Tags
size: 1

Every keystroke, append, and remove records a position. Try ⌘Z / ⌘⇧Z for the keyboard shortcut. clear() reseeds the chain at the current state — useful after a "Save" milestone.

The option

useForm({
  schema,
  history: true, // default 128-position bounded chain
})

Tune the depth:

useForm({ schema, history: { max: 200 } })

Disable explicitly:

useForm({ schema, history: false })

When omitted, history defaults to false. The namespace is still present on the form return so templates don't need conditional logic, but every method is a no-op and the flags read false / 0.

The namespace

All undo/redo surface lives under form.history:

MemberTypeWhat it does
undo()() => booleanStep back to the previous state. false at baseline.
redo()() => booleanReplay the next state after an undo. false when nothing's queued.
clear()() => voidWipe the chain; reseed at the current state as the new baseline.
canUndobooleanGate an "Undo" button reactively.
canRedobooleanGate a "Redo" button reactively.
sizenumberReachable positions across the chain (useful for debug overlays).

What gets captured

Every form value mutation: setValue, register-backed input edits, any array helper (append, prepend, insert, remove, swap, move, replace), or a programmatic write. Each recorded position carries:

  • The form value.
  • The error map at the time of the captured position.
  • The blankPaths set (so cleared-but-defaulted numeric fields keep showing as empty after an undo, instead of resurrecting their slim default).

What's NOT captured:

  • Field interaction state: touched / focused / blurred / connected. UI interaction history; it shouldn't rewind. A field that was touched stays touched.
  • Submission lifecycle: meta.submissionAttempts, meta.submitError.
  • Validation in-flight state.

Calling setFieldErrors / addFieldErrors / clearFieldErrors does NOT record a position; those only touch the error map. Whatever errors are live when the next mutation lands go into that mutation's delta.

Keyboard shortcuts

Not wired by default; wire them in a few lines:

<script setup lang="ts">
  function onKeydown(event: KeyboardEvent) {
    if ((event.metaKey || event.ctrlKey) && event.key === 'z') {
      event.preventDefault()
      event.shiftKey ? form.history.redo() : form.history.undo()
    }
  }
</script>

<template>
  <form @keydown="onKeydown">
    <!-- … -->
  </form>
</template>

Attaform stays out of the global keydown business so you can layer shortcuts at the right scope (per-form, per-route, global), with the modifier convention that fits your platform.

clear() at a milestone

After a "save successful" moment, or any point where consumers should lose access to the prior chain without disturbing the rendered form, call clear(). The form value, errors, and blank-paths stay exactly where they are; only the past and future history reset.

async function onSaveSuccess() {
  await api.commit(form.values())
  form.history.clear()
}

After clear(): canUndo === false, canRedo === false, size === 1. The current position is still reachable; there's just nothing on either side of it.

Interactions

  • reset() is itself a mutation; the pre-reset state stays one undo away. Consumers who want a hard wipe call form.history.clear() after reset(), or pop a confirmation dialog before calling reset().
  • Live field validation still runs on undo / redo; the restored state validates like any other.
  • Persistence picks up each undo / redo as a normal mutation and writes the restored state to the configured backend.
  • Persistence hydration is the floor: once the hydrated value applies, the chain reseeds and undo() can't reach back into the transient pre-hydration default.

Memory

The default max: 128 keeps at most 128 reachable positions across the undo + redo halves combined. Bump it for editors with long histories; drop it for memory-constrained targets. Internally history stores one base snapshot plus a chain of forward deltas (per-mutation Patch[] from the diff machinery), so each additional position costs O(changed-leaf-count). Typing one character into one field allocates a single patch, not a clone of the whole form.

Where to next

  • Multi-tab sync: values converge across tabs; the history chain stays tab-local (each tab walks its own user's intent).
  • reset & resetField: recorded as positions; the pre-reset state stays one undo away.
  • Persistence overview: picks up undo / redo as normal mutations.