Wie ein einfacher History-Stack Formulareingaben nachvollziehbar und rückgängig machbar macht
Eine Rückgängig-Funktion wirkt auf den ersten Blick wie ein Feature, das eine ausgewachsene State-Management-Bibliothek voraussetzt. Tatsächlich lässt sich ein solides Undo/Redo-Pattern mit einem einfachen History-Array und wenigen Zeilen Alpine-Code umsetzen. Dieser Artikel zeigt die Snapshot-basierte Umsetzung an einem Formular-Beispiel, das Debouncing von Änderungen und die konkreten Grenzen, an die dieser Ansatz bei tief verschachteltem State stößt.
Inhaltsverzeichnis
- 1. Warum ein einfacher Undo-Stack oft ausreicht
- 2. Snapshot-basiert versus Command-Pattern: zwei grundsätzliche Wege
- 3. Store-Implementierung mit History-Array und Zeiger
- 4. Praxisbeispiel: Formular mit Rückgängig-Button
- 5. Änderungen debouncen und batchen statt jeden Tastendruck zu speichern
- 6. Redo-Stack und Verzweigung bei neuer Änderung nach Undo
- 7. Speicherverbrauch der History begrenzen
- 8. Grenzen bei komplexem verschachteltem State
- 9. Tastenkürzel Cmd+Z und Cmd+Shift+Z verdrahten
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein einfacher Undo-Stack oft ausreicht
Sobald Nutzer in einem längeren Formular oder Editor mehrere Änderungen hintereinander vornehmen, entsteht früher oder später der Wunsch nach einem Rückgängig-Button. Statt dafür sofort auf eine vollwertige State-Management-Lösung mit Reducer-Pattern zu setzen, reicht in den meisten praktischen Fällen ein simples Array, das frühere Zustände des betroffenen Datenmodells der Reihe nach speichert. Diese Herangehensweise heißt Snapshot-basiertes Undo und ist erstaunlich robust, solange der zu sichernde Zustand überschaubar bleibt.
Der entscheidende Vorteil dieses Ansatzes liegt in seiner Einfachheit: Es gibt keine Befehle, die rückgängig gemacht werden müssen, keine inversen Operationen, die für jede Aktion einzeln definiert werden müssten, sondern lediglich vollständige Kopien des Zustands zu bestimmten Zeitpunkten. Rückgängig machen bedeutet dann schlicht, den vorherigen Snapshot wiederherzustellen, was sich in Alpine.js mit einem reaktiven Array und wenigen Methoden abbilden lässt.
2. Snapshot-basiert versus Command-Pattern: zwei grundsätzliche Wege
Neben dem Snapshot-Ansatz existiert das Command-Pattern, bei dem jede Änderung als eigenständiges Objekt mit einer execute- und einer undo-Methode modelliert wird. Für Aktionen wie das Verschieben eines Elements lässt sich die Umkehrung meist einfach berechnen, etwa indem die alte Position gespeichert wird. Der Vorteil liegt im deutlich geringeren Speicherbedarf, weil nur die eigentliche Änderung und nicht der gesamte Zustand gesichert werden muss.
Der Nachteil des Command-Patterns zeigt sich bei komplexeren Formularen: Für jedes einzelne Feld, jede Validierungsregel und jede abgeleitete Berechnung müsste eine eigene, korrekt invertierbare Operation definiert werden, was den Implementierungsaufwand erheblich erhöht. Für die meisten Formular-Szenarien überwiegt deshalb der pragmatische Vorteil des Snapshot-Ansatzes: Er funktioniert unabhängig davon, wie viele Felder ein Formular besitzt oder wie diese sich gegenseitig beeinflussen.
3. Store-Implementierung mit History-Array und Zeiger
Der History-Store speichert eine geordnete Liste vergangener Zustände sowie einen Zeiger, der auf den aktuell aktiven Eintrag zeigt. Beim ersten Aufruf von record() wird der Anfangszustand gesichert, jeder weitere Aufruf hängt einen neuen Snapshot an. Wichtig ist, dass beim Aufzeichnen eines neuen Zustands alle Einträge nach dem aktuellen Zeiger verworfen werden, denn eine neue Änderung nach einem Undo macht die zuvor existierende Redo-Historie ungültig.
Die Methoden undo() und redo() bewegen den Zeiger lediglich um eine Position und liefern den an dieser Stelle gespeicherten Zustand zurück. Die eigentliche Formular-Komponente muss diesen Zustand dann nur noch auf ihre lokalen Felder anwenden, ohne selbst irgendeine Historie zu verwalten.
// resources/js/stores/undo-history.js
document.addEventListener('alpine:init', () => {
Alpine.store('undoHistory', {
entries: [],
pointer: -1,
maxEntries: 50,
record(snapshot) {
// Redo-Aeste nach dem aktuellen Zeiger verwerfen
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. Praxisbeispiel: Formular mit Rückgängig-Button
Im folgenden Beispiel meldet ein Adressformular jede abgeschlossene Änderung an den History-Store, sobald das jeweilige Feld den Fokus verliert. Der Rückgängig-Button ist deaktiviert, solange canUndo() falsch zurückgibt, sodass Nutzer niemals versehentlich über den Anfang der Historie hinaus zurückspringen können. Das Wiederherstellen eines Snapshots überschreibt dabei sämtliche Formularfelder gleichzeitig, nicht nur das zuletzt geänderte.
Bemerkenswert ist, dass die Formular-Komponente selbst keinerlei Wissen über die Undo-Mechanik besitzt. Sie reicht lediglich ihren aktuellen Zustand an den Store weiter und übernimmt im Gegenzug den Zustand, den ihr der Store nach einem Undo oder Redo zurückgibt, ohne jemals selbst zu prüfen, ob sich gerade ein Undo- oder ein Redo-Vorgang abspielt.
<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()">
Rückgängig
</button>
</form>
5. Änderungen debouncen und batchen statt jeden Tastendruck zu speichern
Würde jeder einzelne Tastendruck einen neuen Snapshot erzeugen, entstünde bei einem längeren Textfeld innerhalb weniger Sekunden eine unübersichtliche Menge an History-Einträgen, von denen die meisten für den Nutzer keine sinnvolle Rückgängig-Grenze darstellen. Sinnvoller ist es, Änderungen zu bündeln und erst dann einen Snapshot zu erzeugen, wenn der Nutzer für eine kurze Zeitspanne mit dem Tippen aufgehört hat, oder eben beim Verlassen des Feldes.
Alpine liefert dafür bereits den eingebauten .debounce-Modifikator, der sich direkt an x-model oder an einen Event-Listener anhängen lässt. Ein typischer Wert liegt zwischen 500 und 800 Millisekunden, lang genug, um normales Tippen nicht als mehrere getrennte Änderungen zu behandeln, aber kurz genug, damit ein Undo-Schritt für den Nutzer noch nachvollziehbar bleibt.
6. Redo-Stack und Verzweigung bei neuer Änderung nach Undo
Ein oft übersehener Sonderfall entsteht, wenn der Nutzer nach einem oder mehreren Undo-Schritten eine völlig neue Änderung vornimmt, statt vorwärts zu redo. In diesem Moment wird die bisherige Redo-Historie technisch ungültig, denn sie baute auf einem Zustand auf, der jetzt durch eine andere Änderung ersetzt wurde. Die einzig konsistente Lösung besteht darin, alle Einträge nach dem aktuellen Zeiger beim nächsten record()-Aufruf konsequent zu verwerfen.
Manche Editoren gehen einen Schritt weiter und bieten eine echte History-Baumstruktur an, in der verworfene Redo-Äste erhalten bleiben und später wieder aufgerufen werden können. Für die überwiegende Mehrheit der Formular- und Editor-Anwendungsfälle ist dieser Aufwand jedoch unverhältnismäßig, ein linearer Stack mit konsequentem Verwerfen genügt für nahezu alle praktischen Anforderungen völlig.
7. Speicherverbrauch der History begrenzen
Da jeder Snapshot eine vollständige, tiefe Kopie des betroffenen Zustands enthält, wächst der Speicherbedarf der Historie linear mit der Anzahl der gespeicherten Einträge. Bei kleinen Formularen mit wenigen Feldern spielt das praktisch keine Rolle, bei umfangreicheren Editoren mit größeren Datenstrukturen lohnt sich jedoch eine feste Obergrenze, ab der die ältesten Einträge automatisch aus dem Array entfernt werden.
Im Store-Beispiel weiter oben übernimmt maxEntries genau diese Aufgabe: Sobald die Liste die festgelegte Grenze überschreitet, entfernt shift() den ältesten Eintrag. Für die allermeisten Anwendungen sind fünfzig Einträge mehr als ausreichend, weil kaum ein Nutzer tatsächlich fünfzig Schritte weit zurückspringen möchte, ohne zwischendurch die Seite neu zu laden oder das Formular ganz zu verlassen.
8. Grenzen bei komplexem verschachteltem State
Der Snapshot-Ansatz stößt an klare Grenzen, sobald der zu sichernde Zustand tief verschachtelte Objekte, dynamische Arrays mit eigenen IDs oder Referenzen auf DOM-Elemente enthält. structuredClone() löst zwar das Kopierproblem für die meisten JSON-kompatiblen Datenstrukturen elegant, scheitert aber an Funktionen, DOM-Knoten oder zyklischen Referenzen, die in komplexeren Alpine-Komponenten durchaus vorkommen können.
Ein weiteres Problem entsteht bei sehr großen, verschachtelten Listen: Wird für jede kleine Änderung der komplette Baum kopiert, wächst sowohl der Speicherverbrauch als auch die Zeit für jeden Snapshot spürbar. In solchen Fällen lohnt sich ein Wechsel zu einem Diff-basierten Ansatz, der nur die tatsächlich veränderten Pfade speichert, oder eine gezielte Kombination aus Snapshot für einfache Felder und Command-Pattern für strukturelle Änderungen wie das Hinzufügen oder Entfernen von Listenelementen.
9. Tastenkürzel Cmd+Z und Cmd+Shift+Z verdrahten
Ein Rückgängig-Button allein deckt nur einen Teil der Nutzererwartung ab, denn erfahrene Anwender greifen reflexartig zu Cmd+Z beziehungsweise Strg+Z. Ein globaler Tastatur-Listener, der auf diese Kombination reagiert und dabei prüft, ob gerade ein normales Texteingabefeld des Browsers fokussiert ist, verhindert dabei Konflikte mit der nativen Undo-Funktion einzelner Textfelder.
Wichtig ist, das Standardverhalten des Browsers für dieses spezielle Formular gezielt zu unterbinden, sonst würde der native Undo-Mechanismus einzelner Eingabefelder mit der eigenen Store-Logik konkurrieren und zu inkonsistenten Zuständen führen. Ein @keydown.cmd.z.window.prevent beziehungsweise die entsprechende Ctrl-Variante für Windows und Linux deckt diesen Fall bereits mit den in Alpine eingebauten Modifikatoren zuverlässig ab.
| Strategie | Funktionsweise | Geeignet für | Schwäche |
|---|---|---|---|
| Snapshot-basiert | Vollständige Kopie des Zustands bei jeder Änderung | Formulare und Editoren mit überschaubarer Feldanzahl | Speicherbedarf wächst linear mit der Historie |
| Command-Pattern | Jede Aktion als execute/undo-Paar mit inverser Operation | Klar definierte Einzelaktionen wie Verschieben oder Löschen | Für jede Aktionsart eine eigene Umkehrung nötig |
| Diff-basiert | Nur die veränderten Pfade eines Objekts werden gespeichert | Große, tief verschachtelte Datenstrukturen | Zusammenführen der Diffs beim Undo ist fehleranfälliger |
| Server-seitige Versionierung | Vollständige Zustände werden serverseitig als Revisionen abgelegt | Kollaborative Dokumente mit langfristiger Historie | Zusätzliche Latenz, ungeeignet für sofortiges Undo im UI |
| Hybrid-Ansatz | Snapshot für einfache Felder, Command-Pattern für Strukturänderungen | Komplexe Editoren mit gemischten Anforderungen | Höherer Implementierungsaufwand als ein einzelner Ansatz |
Mironsoft
Alpine.js-Interaktivität für Hyvä-Frontends
Hyvä-Frontend, das mehr Interaktivität braucht, aber ohne React-Overhead?
Wir bauen interaktive Frontend-Komponenten für Hyvä-Themes mit Alpine.js, leichtgewichtig und ohne Build-Step-Komplexität, von einfachen Toggles bis zu komplexen Formular-Flows.
Custom-Komponenten
Interaktive Alpine.js-Komponenten für spezifische Shop-Anforderungen entwickeln.
Performance-Review
Bestehende Alpine.js-Implementierungen auf Reaktivitäts-Fallen und Performance prüfen.
Team-Schulung
Entwickler in Alpine.js-Patterns für Hyvä-Themes praxisnah einarbeiten.
10. Zusammenfassung
Undo/Redo in Alpine.js: Das Wichtigste auf einen Blick
Snapshot statt Command
Ein einfaches History-Array mit vollständigen Zustandskopien deckt die meisten Formular-Szenarien ab, ohne inverse Operationen definieren zu müssen.
Debouncing verhindert Rauschen
Änderungen werden gebündelt und erst nach einer kurzen Pause oder beim Verlassen des Feldes als Snapshot gesichert.
Redo-Äste konsequent verwerfen
Eine neue Änderung nach einem Undo macht die bisherige Redo-Historie ungültig und muss verworfen werden.
Grenzen bei tiefem State erkennen
structuredClone scheitert an Funktionen und zyklischen Referenzen, für sehr große verschachtelte Strukturen lohnt ein Diff-basierter Ansatz.