How a simple history stack makes form input traceable and reversible
An undo feature looks at first glance like something that requires a full-blown state management library. In practice, a solid undo/redo pattern can be built with a simple history array and a handful of lines of Alpine code. This article walks through a snapshot-based implementation using a form example, covers debouncing changes, and lays out exactly where this approach hits its limits with deeply nested state.
Table of Contents
- 1. Why a simple undo stack is often enough
- 2. Snapshot-based versus command pattern: two fundamentally different paths
- 3. Store implementation with a history array and pointer
- 4. Practical example: a form with an undo button
- 5. Debouncing and batching changes instead of saving every keystroke
- 6. Redo stack and branching on a new change after undo
- 7. Limiting the history's memory usage
- 8. Limits with complex nested state
- 9. Wiring up Cmd+Z and Cmd+Shift+Z shortcuts
- 10. Summary
- 11. FAQ
1. Why a simple undo stack is often enough
As soon as users make several changes in a row inside a longer form or editor, the request for an undo button shows up sooner or later. Instead of immediately reaching for a full state management solution with a reducer pattern, a plain array that stores previous states of the affected data model in order is enough for most practical cases. This approach is called snapshot-based undo and turns out to be remarkably robust as long as the state being captured stays manageable.
The key advantage of this approach lies in its simplicity: there are no commands that need to be reversed, no inverse operations that would need to be defined individually for every action, just full copies of the state at specific points in time. Undoing then simply means restoring the previous snapshot, which maps neatly onto a reactive array and a handful of methods in Alpine.js.
2. Snapshot-based versus command pattern: two fundamentally different paths
Alongside the snapshot approach, there is the command pattern, where every change is modeled as its own object with an execute and an undo method. For actions like moving an element, the reversal is usually easy to compute, for example by storing the previous position. The advantage is a much smaller memory footprint, because only the actual change needs to be stored, not the entire state.
The downside of the command pattern shows up with more complex forms: every single field, every validation rule, and every derived calculation would need its own, correctly invertible operation, which significantly increases implementation effort. For most form scenarios, the pragmatic advantage of the snapshot approach wins out: it works regardless of how many fields a form has or how those fields affect each other.
3. Store implementation with a history array and pointer
The history store keeps an ordered list of past states plus a pointer that points to the currently active entry. On the first call to record(), the initial state is captured, every further call appends a new snapshot. It is important that recording a new state discards every entry after the current pointer, because a new change after an undo invalidates the previously existing redo history.
The undo() and redo() methods simply move the pointer by one position and return the state stored at that spot. The actual form component then only needs to apply that returned state to its local fields, without managing any history itself.
// resources/js/stores/undo-history.js
document.addEventListener('alpine:init', () => {
Alpine.store('undoHistory', {
entries: [],
pointer: -1,
maxEntries: 50,
record(snapshot) {
// Discard redo branches after the current pointer
this.entries = this.entries.slice(0, this.pointer + 1);
this.entries.push(structuredClone(snapshot));
if (this.entries.length > this.maxEntries) {
this.entries.shift();
}
this.pointer = this.entries.length - 1;
},
canUndo() { return this.pointer > 0; },
canRedo() { return this.pointer < this.entries.length - 1; },
undo() {
if (!this.canUndo()) return null;
this.pointer -= 1;
return structuredClone(this.entries[this.pointer]);
},
redo() {
if (!this.canRedo()) return null;
this.pointer += 1;
return structuredClone(this.entries[this.pointer]);
},
});
});
4. Practical example: a form with an undo button
In the example below, an address form reports every completed change to the history store as soon as the relevant field loses focus. The undo button stays disabled while canUndo() returns false, so users can never accidentally jump back past the beginning of the history. Restoring a snapshot overwrites every form field at once, not just the one that was most recently changed.
Notably, the form component itself has no knowledge of the undo mechanics at all. It simply passes its current state to the store, and in return adopts whatever state the store hands back after an undo or redo, without ever checking itself whether an undo or a redo is currently happening.
<form
x-data="{
fields: { firstName: '', lastName: '', street: '' },
init() {
this.$store.undoHistory.record(this.fields);
},
commit() {
this.$store.undoHistory.record(this.fields);
},
applyUndo() {
const snapshot = this.$store.undoHistory.undo();
if (snapshot) this.fields = snapshot;
},
}"
>
<input x-model="fields.firstName" @blur="commit" name="firstName">
<input x-model="fields.lastName" @blur="commit" name="lastName">
<input x-model="fields.street" @blur="commit" name="street">
<button type="button" @click="applyUndo" :disabled="!$store.undoHistory.canUndo()">
Undo
</button>
</form>
5. Debouncing and batching changes instead of saving every keystroke
If every single keystroke created a new snapshot, a longer text field would produce an unmanageable number of history entries within seconds, most of which would not represent a meaningful undo boundary for the user. It makes far more sense to batch changes and only create a snapshot once the user has paused typing for a short interval, or simply when the field loses focus.
Alpine already ships with a built-in .debounce modifier that can be attached directly to x-model or to an event listener. A typical value sits between 500 and 800 milliseconds, long enough not to treat normal typing as several separate changes, but short enough that a single undo step still stays understandable for the user.
6. Redo stack and branching on a new change after undo
An often overlooked edge case shows up when the user makes a brand new change after one or more undo steps, instead of moving forward with redo. At that moment, the existing redo history technically becomes invalid, because it was built on a state that has now been replaced by a different change. The only consistent solution is to consistently discard every entry after the current pointer on the next record() call.
Some editors go a step further and offer a genuine history tree, in which discarded redo branches are preserved and can be revisited later. For the vast majority of form and editor use cases, that effort is disproportionate though, a linear stack with consistent discarding is entirely sufficient for nearly all practical requirements.
7. Limiting the history's memory usage
Since every snapshot holds a complete, deep copy of the affected state, the history's memory usage grows linearly with the number of stored entries. For small forms with only a few fields that practically does not matter, but for larger editors with bigger data structures, a fixed upper limit is worth having, past which the oldest entries automatically get removed from the array.
In the store example above, maxEntries handles exactly that job: as soon as the list exceeds the configured limit, shift() removes the oldest entry. For the vast majority of applications, fifty entries are more than enough, because hardly any user actually wants to jump back fifty steps without reloading the page or leaving the form entirely in between.
8. Limits with complex nested state
The snapshot approach runs into clear limits once the state being captured contains deeply nested objects, dynamic arrays with their own IDs, or references to DOM elements. structuredClone() elegantly solves the copying problem for most JSON-compatible data structures, but it fails on functions, DOM nodes, or circular references, which can genuinely show up in more complex Alpine components.
A second problem appears with very large, nested lists: if the entire tree gets copied for every small change, both memory usage and the time each snapshot takes noticeably grow. In those cases, switching to a diff-based approach that only stores the paths that actually changed is worthwhile, or a targeted combination of snapshots for simple fields and a command pattern for structural changes like adding or removing list items.
9. Wiring up Cmd+Z and Cmd+Shift+Z shortcuts
An undo button alone only covers part of what users expect, because experienced users instinctively reach for Cmd+Z or Ctrl+Z. A global keyboard listener that reacts to that combination while checking whether a regular browser text input is currently focused prevents conflicts with the native undo behavior of individual text fields.
It is important to deliberately block the browser's default behavior for this specific form, otherwise the native undo mechanism of individual input fields would compete with your own store logic and produce inconsistent state. A @keydown.cmd.z.window.prevent, or the corresponding Ctrl variant for Windows and Linux, already covers this case reliably using Alpine's built-in modifiers.
| Strategy | How it works | Suited for | Weakness |
|---|---|---|---|
| Snapshot-based | Full copy of the state on every change | Forms and editors with a manageable number of fields | Memory usage grows linearly with the history |
| Command pattern | Every action as an execute/undo pair with an inverse operation | Clearly defined individual actions like moving or deleting | Every action type needs its own reversal |
| Diff-based | Only the changed paths of an object get stored | Large, deeply nested data structures | Merging diffs on undo is more error-prone |
| Server-side versioning | Full states get stored as revisions on the server | Collaborative documents with a long-term history | Extra latency, unsuitable for instant undo in the UI |
| Hybrid approach | Snapshots for simple fields, command pattern for structural changes | Complex editors with mixed requirements | Higher implementation effort than a single approach |
Mironsoft
Alpine.js interactivity for Hyvä frontends
A Hyvä frontend that needs more interactivity, but without React overhead?
We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.
Custom Components
Develop interactive Alpine.js components for specific shop requirements.
Performance Review
Review existing Alpine.js implementations for reactivity pitfalls and performance.
Team Training
Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.
10. Summary
Undo/Redo in Alpine.js: Key Takeaways
Snapshot instead of command
A simple history array with full state copies covers most form scenarios without having to define inverse operations.
Debouncing prevents noise
Changes get batched and only saved as a snapshot after a short pause or when the field loses focus.
Discard redo branches consistently
A new change after an undo invalidates the existing redo history, which must be discarded.
Know the limits with deep state
structuredClone fails on functions and circular references; for very large nested structures, a diff-based approach is worthwhile.