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