Headless undo/redo history controller for Alpine.js — transactions, persistence, and configurable limits.
CSS-framework agnostic — no markup, no styles. The controller tracks value snapshots and emits structured events; you wire your own UI.
pnpm add @ailuracode/alpine-history @ailuracode/alpine-core alpinejsimport Alpine from "alpinejs";
import history from "@ailuracode/alpine-history";
Alpine.plugin(history({ initialValue: 0 }));
Alpine.start();<div>
<output x-text="$store.history.value"></output>
<button @click="$store.history.commit($store.history.value + 1)">+1</button>
<button @click="$store.history.undo()" :disabled="!$store.history.canUndo">Undo</button>
<button @click="$store.history.redo()" :disabled="!$store.history.canRedo">Redo</button>
</div>| Method / Property | Description |
|---|---|
commit(value, meta?) |
Record a new value in the undo stack |
undo() |
Pop the last entry and push it to redo; returns the restored value |
redo() |
Pop the last redo entry and push it to undo; returns the restored value |
canUndo |
true when the undo stack is non-empty |
canRedo |
true when the redo stack is non-empty |
clear() |
Empty both undo and redo stacks |
reset(value?, meta?) |
Clear stacks and commit a fresh initial value |
checkpoint(meta?) |
Snapshot the current value without changing it |
transaction(initialValue) |
Start a batch — returns a TransactionHandle with .commit() and .rollback() |
value |
Current value (may be undefined before first commit) |
undoStack |
Shallow copy of the undo entries |
redoStack |
Shallow copy of the redo entries |
transactionDepth |
> 0 while a transaction is active |
destroy() |
Tear down the controller and release resources |
The callable magic $history is a shorthand for commit:
<button @click="$history($store.history.value + 1)">+1</button>The magic also exposes read-only accessors: $history.current, $history.canUndo, $history.canRedo, and all store methods (undo, redo, clear, reset, checkpoint, transaction).
| Option | Type | Default | Description |
|---|---|---|---|
initialValue |
T |
undefined |
Seed value — visible as value but not on the undo stack until the first commit |
limit |
number |
100 |
Maximum entries in the undo stack |
maxSize |
number |
undefined |
Estimated byte budget; oldest entries are evicted first |
clone |
(value: T) => T |
structuredClone |
Deep-clone strategy for entries |
equality |
(a: T, b: T) => boolean |
Object.is |
Deduplication for consecutive identical commits |
debounceMs |
number |
undefined |
Debounce rapid commits |
nestedTransactionPolicy |
"stack" | "abort" |
"stack" |
How to handle nested .transaction() calls |
persistence |
PersistenceAdapter<T> |
undefined |
Optional adapter for persisting undo history |
storeKey |
string |
"history" |
Alpine store key |
magicKey |
string |
"history" |
$history magic key |
Transactions batch multiple commits into a single undo entry:
const tx = $store.history.transaction(currentValue);
$store.history.commit(newValue1);
$store.history.commit(newValue2);
tx.commit(); // pushes a single undo entryCalling tx.rollback() discards all intermediate commits and restores the snapshot taken when transaction() was called.
If your application already owns a $history store or magic — or another toolkit plugin registers on that name — rename the integration surface without touching the controller:
Alpine.plugin(
historyPlugin({
storeKey: "undoStack", // → $store.undoStack
magicKey: "undo", // → $undo
}),
);The exposed constants DEFAULT_HISTORY_MAGIC_KEY and HISTORY_STORE_KEY keep the renames discoverable from TypeScript.
Implement the PersistenceAdapter interface to persist undo history across reloads:
const localStorageAdapter = {
load() {
const raw = localStorage.getItem("my-history");
return raw ? JSON.parse(raw) : [];
},
save(entries) {
localStorage.setItem("my-history", JSON.stringify(entries));
},
clear() {
localStorage.removeItem("my-history");
},
};
Alpine.plugin(history({ persistence: localStorageAdapter }));Use the controller directly for non-Alpine environments or testing:
import { HistoryController } from "@ailuracode/alpine-history";
const controller = new HistoryController({ initialValue: 0 });
controller.mount();
controller.commit(1);
controller.commit(2);
controller.undo(); // restores 1
controller.on("change", (detail) => {
console.log(detail.value, detail.canUndo, detail.canRedo, detail.source);
});The change event fires on every mutation with a typed source:
source |
Trigger |
|---|---|
"initialization" |
Controller mounted |
"commit" |
New value committed |
"undo" |
Undo navigation |
"redo" |
Redo navigation |
"clear" |
Stacks emptied |
"reset" |
Reset to fresh state |
"checkpoint" |
Snapshot recorded |
"transaction:start" |
Transaction opened |
"transaction:commit" |
Transaction committed |
"transaction:rollback" |
Transaction rolled back |
- Core — plugin registry (
registerPlugin,initPlugins)
MIT