Skip to content

@stateloom/history ​

Snapshot-based undo/redo middleware for stores.

Install ​

bash
pnpm add @stateloom/core @stateloom/store @stateloom/history
bash
npm install @stateloom/core @stateloom/store @stateloom/history
bash
yarn add @stateloom/core @stateloom/store @stateloom/history

Size: ~0.4 KB gzipped (+ core)

Overview ​

The history middleware records a full state snapshot before each setState call. It provides undo() and redo() methods plus reactive canUndo/canRedo signals that integrate with computed(), effect(), and framework adapters.

Quick Start ​

typescript
import { createStore } from '@stateloom/store';
import { history } from '@stateloom/history';

const h = history<{ count: number; increment: () => void }>();
const store = createStore(
  (set) => ({
    count: 0,
    increment: () => set((s) => ({ count: s.count + 1 })),
  }),
  { middleware: [h] },
);

store.getState().increment(); // count: 1
store.getState().increment(); // count: 2
h.undo(); // count: 1
h.redo(); // count: 2
h.canUndo.get(); // true
h.canRedo.get(); // false

Guide ​

Creating the Middleware ​

Call history() with optional configuration. The returned object is both a middleware (passable to createStore) and a controller with undo/redo methods.

typescript
import { history } from '@stateloom/history';

// With defaults (maxDepth: 100)
const h = history();

// With custom depth
const h = history({ maxDepth: 50 });

Attaching to a Store ​

Pass the history instance in the middleware array:

typescript
import { createStore } from '@stateloom/store';

const store = createStore((set) => ({ count: 0 }), { middleware: [h] });

Undo and Redo ​

Each setState call records a snapshot. Call undo() to revert and redo() to re-apply:

typescript
store.setState({ count: 5 });
store.setState({ count: 10 });

h.undo(); // count: 5
h.undo(); // count: 0
h.redo(); // count: 5

A new state change after an undo clears the redo stack:

typescript
store.setState({ count: 1 });
h.undo(); // count: 0
store.setState({ count: 99 }); // redo stack cleared
h.redo(); // no-op, count stays 99

Reactive Signals ​

canUndo and canRedo are ReadonlySignal<boolean> values. Use them with computed(), effect(), or framework adapters:

typescript
import { computed, effect } from '@stateloom/core';

const status = computed(() => `undo:${h.canUndo.get()},redo:${h.canRedo.get()}`);

effect(() => {
  console.log('Can undo:', h.canUndo.get());
});

Clearing History ​

Reset both undo and redo stacks without affecting the current state:

typescript
h.clear();
h.canUndo.get(); // false
h.canRedo.get(); // false

API Reference ​

history<T>(options?): HistoryMiddleware<T> ​

typescript
import { history } from '@stateloom/history';

Parameters:

  • options.maxDepth — Maximum undo snapshots to retain (default: 100). When exceeded, the oldest snapshots are discarded.

Returns: HistoryMiddleware<T> — a middleware instance with undo/redo controls.

HistoryMiddleware<T> ​

typescript
interface HistoryMiddleware<T> {
  // Middleware hooks (used by createStore internally)
  readonly name: string;
  readonly init: (api: MiddlewareAPI<T>) => void;
  readonly onSet: (api: MiddlewareAPI<T>, next: SetFn<T>, partial: Partial<T>) => void;
  readonly onDestroy: (api: MiddlewareAPI<T>) => void;

  // Undo/redo controls
  undo(): void;
  redo(): void;
  clear(): void;
  readonly canUndo: ReadonlySignal<boolean>;
  readonly canRedo: ReadonlySignal<boolean>;
}
Method / SignalDescription
undo()Revert to previous snapshot. No-op if nothing to undo.
redo()Re-apply last undone snapshot. No-op if nothing to redo.
clear()Clear all undo/redo history. State is unchanged.
canUndoReadonlySignal<boolean> — true when undo stack is non-empty.
canRedoReadonlySignal<boolean> — true when redo stack is non-empty.

HistoryOptions ​

typescript
interface HistoryOptions {
  readonly maxDepth?: number; // default: 100
}

Patterns ​

Undo/Redo Toolbar ​

Bind canUndo/canRedo to button disabled states:

typescript
// React example
import { useSignalValue } from '@stateloom/react';

function UndoToolbar({ history }: { history: HistoryMiddleware<AppState> }) {
  const canUndo = useSignalValue(history.canUndo);
  const canRedo = useSignalValue(history.canRedo);

  return (
    <div>
      <button disabled={!canUndo} onClick={() => history.undo()}>Undo</button>
      <button disabled={!canRedo} onClick={() => history.redo()}>Redo</button>
    </div>
  );
}

Combining with Other Middleware ​

History plays well with other middleware. During undo/redo, the state change goes through the full middleware chain — devtools, persist, and other middleware see the change:

typescript
import { createStore } from '@stateloom/store';
import { history } from '@stateloom/history';

const h = history();
const store = createStore((set) => ({ count: 0 }), {
  middleware: [
    h, // history records snapshots
    logger(), // logger sees undo/redo changes
  ],
});

Scoped History (Multiple Stores) ​

Each history() call creates an independent instance:

typescript
const editorHistory = history({ maxDepth: 200 });
const settingsHistory = history({ maxDepth: 20 });

const editorStore = createStore(editorCreator, { middleware: [editorHistory] });
const settingsStore = createStore(settingsCreator, { middleware: [settingsHistory] });

// Independent undo stacks
editorHistory.undo(); // only affects editorStore
settingsHistory.undo(); // only affects settingsStore

How It Works ​

Snapshot Strategy ​

On each setState call, the onSet hook captures a full snapshot of the current state before the update is applied:

Time Travel Guard ​

During undo()/redo(), the middleware calls api.setState() which re-enters the middleware chain. An internal isTimeTraveling flag prevents history from re-recording its own operations:

Other middleware in the chain still sees the state change, so devtools and persist are notified during undo/redo.

Structural Sharing ​

Snapshots are full state objects, but unchanged nested values naturally share references:

typescript
const store = createStore((set) => ({
  user: { name: 'Alice' },
  settings: { theme: 'dark' },
}));

// Only updates user — settings object is shared across snapshots
store.setState({ user: { name: 'Bob' } });

// After undo, settings reference is the same object

This happens because Object.assign preserves references for unchanged keys.

maxDepth Trimming ​

When the undo stack exceeds maxDepth, the oldest entries are removed from the front of the array, keeping only the most recent snapshots.

TypeScript ​

The history() function accepts an explicit generic for the store state type:

typescript
interface AppState {
  count: number;
  increment: () => void;
}

const h = history<AppState>({ maxDepth: 50 });

The returned HistoryMiddleware<T> is structurally compatible with Middleware<T> from @stateloom/store, so no type casts are needed when passing it to createStore.

When to Use ​

ScenarioRecommendation
Form editing with undoUse @stateloom/history
Canvas / drawing appUse @stateloom/history (consider lower maxDepth)
Simple CRUD appProbably unnecessary
Large state with frequent updatesConsider command-based strategy (future)
Need to persist undo across sessionsCombine with @stateloom/persist