@stateloom/history
Snapshot-based undo/redo middleware for stores.
Install
pnpm add @stateloom/core @stateloom/store @stateloom/historynpm install @stateloom/core @stateloom/store @stateloom/historyyarn add @stateloom/core @stateloom/store @stateloom/historySize: ~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
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(); // falseGuide
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.
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:
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:
store.setState({ count: 5 });
store.setState({ count: 10 });
h.undo(); // count: 5
h.undo(); // count: 0
h.redo(); // count: 5A new state change after an undo clears the redo stack:
store.setState({ count: 1 });
h.undo(); // count: 0
store.setState({ count: 99 }); // redo stack cleared
h.redo(); // no-op, count stays 99Reactive Signals
canUndo and canRedo are ReadonlySignal<boolean> values. Use them with computed(), effect(), or framework adapters:
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:
h.clear();
h.canUndo.get(); // false
h.canRedo.get(); // falseAPI Reference
history<T>(options?): HistoryMiddleware<T>
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>
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 / Signal | Description |
|---|---|
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. |
canUndo | ReadonlySignal<boolean> — true when undo stack is non-empty. |
canRedo | ReadonlySignal<boolean> — true when redo stack is non-empty. |
HistoryOptions
interface HistoryOptions {
readonly maxDepth?: number; // default: 100
}Patterns
Undo/Redo Toolbar
Bind canUndo/canRedo to button disabled states:
// 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:
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:
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 settingsStoreHow 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:
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 objectThis 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:
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
| Scenario | Recommendation |
|---|---|
| Form editing with undo | Use @stateloom/history |
| Canvas / drawing app | Use @stateloom/history (consider lower maxDepth) |
| Simple CRUD app | Probably unnecessary |
| Large state with frequent updates | Consider command-based strategy (future) |
| Need to persist undo across sessions | Combine with @stateloom/persist |