History Design
Low-level design for @stateloom/history — the snapshot-based undo/redo middleware. Covers the past/future stack data structure, the onSet hook flow, undo/redo operations, depth eviction, reactive signals, and lifecycle management.
Overview
The @stateloom/history package provides undo/redo functionality for stores via full state snapshots. Before each setState call, the middleware captures the current state and pushes it onto the undo stack. The undo() and redo() methods restore previous states by popping from one stack and pushing to the other. Reactive canUndo and canRedo signals enable UI that automatically enables/disables controls.
Data Structure
The history middleware maintains two arrays as stacks and two reactive signals:
const past: T[] = []; // undo stack (most recent at end)
const future: T[] = []; // redo stack (most recent at end)
const canUndoSignal = signal(false);
const canRedoSignal = signal(false);
let isTimeTraveling = false;
let api: MiddlewareAPI<T> | undefined;Both stacks use Array.push() / Array.pop() — the most recent snapshot is always at the end of the array. This gives O(1) push and pop operations.
onSet Hook Flow
The onSet hook records history before each state update:
The implementation:
onSet(middlewareApi: MiddlewareAPI<T>, next: SetFn<T>, partial: Partial<T>): void {
if (!isTimeTraveling) {
past.push(middlewareApi.getState());
if (past.length > maxDepth) {
past.splice(0, past.length - maxDepth);
}
future.length = 0;
}
next(partial);
if (!isTimeTraveling) {
updateSignals();
}
}Key behaviors:
- Before
next(): The current state is pushed ontopast, the future stack is cleared (any undone states are discarded), and depth is enforced - After
next(): Reactive signals are updated to reflect new stack sizes - During time-travel: The guard skips recording and signal updates to prevent the undo/redo operation from being recorded as a new history entry
Why Clear Future on New State
When the user makes a new change after undoing, the redo stack is cleared (future.length = 0). This follows the standard undo/redo convention — branching at a previous state discards the "undone future." This prevents confusing state where redo would jump to a state that no longer makes sense in context.
Undo Operation
function undo(): void {
if (api === undefined || past.length === 0) return;
const current = api.getState();
const previous = past.pop() as T;
future.push(current);
isTimeTraveling = true;
try {
api.setState(() => previous);
} finally {
isTimeTraveling = false;
}
updateSignals();
}The isTimeTraveling guard is wrapped in try/finally to ensure it is always reset, even if setState throws.
Redo Operation
The redo operation mirrors undo, swapping the roles of past and future:
function redo(): void {
if (api === undefined || future.length === 0) return;
const current = api.getState();
const next = future.pop() as T;
past.push(current);
isTimeTraveling = true;
try {
api.setState(() => next);
} finally {
isTimeTraveling = false;
}
updateSignals();
}Both undo() and redo() use api.setState() (not the internal next from the middleware chain). This means the state change goes through the full middleware chain — other middleware like devtools and persist still sees the change and can respond accordingly.
maxDepth Eviction
When the undo stack exceeds maxDepth (default: 100), the oldest entries are discarded via splice:
if (past.length > maxDepth) {
past.splice(0, past.length - maxDepth);
}This removes past.length - maxDepth entries from the beginning of the array (the oldest snapshots), keeping the most recent maxDepth entries.
Reactive Signals
canUndo and canRedo are core signal<boolean> values exposed as ReadonlySignal<boolean>:
const canUndoSignal = signal(false);
const canRedoSignal = signal(false);
function updateSignals(): void {
canUndoSignal.set(past.length > 0);
canRedoSignal.set(future.length > 0);
}Because they are core signals, they integrate with computed(), effect(), and all framework adapters:
// React: useSignal(h.canUndo) → boolean
// Vue: useSignal(h.canUndo) → Ref<boolean>
// Solid: useSignal(h.canUndo) → Accessor<boolean>The signals are narrowed to ReadonlySignal<boolean> in the return type to prevent consumers from calling .set() on the history signals:
canUndo: canUndoSignal as ReadonlySignal<boolean>,
canRedo: canRedoSignal as ReadonlySignal<boolean>,Lifecycle Management
Initialization
The init hook captures the store API reference:
init(middlewareApi: MiddlewareAPI<T>): void {
api = middlewareApi;
}No history is recorded during initialization — the stacks start empty and signals start as false.
Destruction
The onDestroy hook clears all history and releases the API reference:
onDestroy(): void {
clear();
api = undefined;
}The clear() function resets both stacks and updates signals:
function clear(): void {
past.length = 0;
future.length = 0;
updateSignals();
}After destruction:
undo()andredo()become no-ops (guarded byapi === undefined)canUndoandcanRedoare bothfalse
State Diagram
Design Decisions
Why Full State Snapshots
History stores full T objects rather than diffs or patches. This is simpler to implement and guarantees correctness — undo() always restores exactly the previous state. Natural structural sharing occurs because Object.assign in the store's rawSet preserves references for unchanged nested values. A diff-based approach would save memory but add complexity for computing and applying patches.
Why api.setState() Instead of Direct State Replacement
Both undo() and redo() use api.setState() to restore state. This routes the change through the full middleware chain, so other middleware (devtools, persist) responds to undo/redo operations. Using a direct state replacement would bypass the chain, leaving devtools and persist out of sync.
Why the isTimeTraveling Guard
Without the guard, an undo operation would be recorded as a new history entry by the onSet hook, corrupting the stacks. The guard ensures that only genuine user state changes are recorded, while undo/redo operations pass through silently.
Why try/finally for the Guard
Using try/finally ensures isTimeTraveling is always reset, even if api.setState() throws. Without this, a single error during undo/redo would permanently disable history recording.
Why Signals for canUndo/canRedo
Using core signal<boolean> for canUndo and canRedo integrates with the reactive graph. UI components that read these signals are automatically re-rendered when undo/redo availability changes, without manual subscription management.
Why splice for Eviction
Array.splice(0, n) removes the oldest n entries in-place. An alternative would be to use a ring buffer, but splice is simpler and the cost is O(maxDepth) which is bounded and infrequent (only when the stack overflows).
Performance Considerations
| Concern | Strategy | Cost |
|---|---|---|
| Snapshot storage | Full state objects in arrays; structural sharing via Object.assign | O(d) memory where d = maxDepth |
| Push/pop | Array push/pop at end | O(1) amortized |
| Depth eviction | splice(0, overflow) — removes from front | O(maxDepth) when triggered |
| Signal updates | Two signal.set() calls per operation | O(1) per signal |
| Time-travel guard | Single boolean check | O(1) |
| Clear operation | length = 0 for both arrays | O(1) |
| Middleware chain | undo/redo use api.setState() — full chain traversal | O(m) where m = middleware count |
Cross-References
- Middleware Overview — where history fits in the middleware ecosystem
- Architecture Overview — layer structure and dependency rules
- Store Design — middleware pipeline construction and hook execution order
- Core Design — signal primitives used for canUndo/canRedo
- API Reference:
@stateloom/history— consumer-facing documentation