Skip to content

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:

typescript
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:

typescript
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 onto past, 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

typescript
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:

typescript
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:

typescript
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>:

typescript
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:

typescript
// 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:

typescript
canUndo: canUndoSignal as ReadonlySignal<boolean>,
canRedo: canRedoSignal as ReadonlySignal<boolean>,

Lifecycle Management

Initialization

The init hook captures the store API reference:

typescript
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:

typescript
onDestroy(): void {
  clear();
  api = undefined;
}

The clear() function resets both stacks and updates signals:

typescript
function clear(): void {
  past.length = 0;
  future.length = 0;
  updateSignals();
}

After destruction:

  • undo() and redo() become no-ops (guarded by api === undefined)
  • canUndo and canRedo are both false

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

ConcernStrategyCost
Snapshot storageFull state objects in arrays; structural sharing via Object.assignO(d) memory where d = maxDepth
Push/popArray push/pop at endO(1) amortized
Depth evictionsplice(0, overflow) — removes from frontO(maxDepth) when triggered
Signal updatesTwo signal.set() calls per operationO(1) per signal
Time-travel guardSingle boolean checkO(1)
Clear operationlength = 0 for both arraysO(1)
Middleware chainundo/redo use api.setState() — full chain traversalO(m) where m = middleware count

Cross-References