https://github.com/mutativejs/zustand-travel
A powerful and high-performance undo/redo middleware for Zustand with Travels
https://github.com/mutativejs/zustand-travel
mutative redo time-travel undo undo-redo zustand
Last synced: 1 day ago
JSON representation
A powerful and high-performance undo/redo middleware for Zustand with Travels
- Host: GitHub
- URL: https://github.com/mutativejs/zustand-travel
- Owner: mutativejs
- License: mit
- Created: 2025-09-30T17:47:27.000Z (7 months ago)
- Default Branch: main
- Last Pushed: 2026-01-25T11:03:28.000Z (3 months ago)
- Last Synced: 2026-02-06T02:58:17.446Z (3 months ago)
- Topics: mutative, redo, time-travel, undo, undo-redo, zustand
- Language: TypeScript
- Homepage:
- Size: 734 KB
- Stars: 21
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# zustand-travel

[](https://www.npmjs.com/package/zustand-travel)

A powerful and high-performance undo/redo middleware for Zustand with [Travels](https://github.com/mutativejs/travels).
## Features
- ✨ **Time Travel**: Full undo/redo, reset, and rebase support for your Zustand stores
- 🎯 **Mutation updates**: Write mutable code that produces immutable updates
- 📦 **Lightweight**: Built on efficient JSON Patch storage
- ⚡ **High Performance**: Powered by [Mutative](https://github.com/unadlib/mutative) (10x faster than Immer)
- 🔧 **Configurable**: Customizable history size and archive modes
- 🔄 **Reactive Controls**: Access time-travel controls anywhere
## Installation
```bash
npm install zustand-travel travels mutative zustand
# or
yarn add zustand-travel travels mutative zustand
# or
pnpm add zustand-travel travels mutative zustand
```
### Version compatibility
| zustand-travel | travels |
| -------------- | ------------------------------------------ |
| `>= 1.1.0` | `>= 1.2.0` (required for `rebase` support) |
| `< 1.1.0` | `< 1.2.0` |
## Quick Start
```typescript
import { create } from 'zustand';
import { travel } from 'zustand-travel';
type State = {
count: number;
};
type Actions = {
increment: (qty: number) => void;
decrement: (qty: number) => void;
};
export const useCountStore = create()(
travel((set) => ({
count: 0,
increment: (qty: number) =>
set((state) => {
state.count += qty; // ⭐ Mutation style for efficient JSON Patches
}),
decrement: (qty: number) =>
set((state) => {
state.count -= qty; // ⭐ Recommended approach
}),
}))
);
// Access controls
const controls = useCountStore.getControls();
controls.back(); // Undo
controls.forward(); // Redo
controls.reset(); // Reset to initial state
controls.rebase(); // Make the current state the new baseline
```
Important behavior:
- `travel(...)` expects the initializer to return an **object store**.
- Only non-function fields are tracked in history. Action functions are preserved and reattached after undo/redo.
- Plain serializable data is the safest default for persistence. If you persist complex values such as `Date`, `Map`, or `Set`, use a custom serialization strategy.
## API
### Middleware Options
```typescript
travel(initializer, options?)
```
The initial data state comes from `initializer`, not from `options`. `options` are forwarded to `Travels`, except `mutable`, which is intentionally disabled because Zustand already manages immutable store replacement.
| Option | Type | Default | Description |
| ---------------------- | ------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `maxHistory` | number | 10 | Maximum number of history entries to keep. Must be a non-negative integer. `0` disables undo/redo history. |
| `initialPatches` | TravelPatches | {patches: [],inversePatches: []} | Restore saved patches when loading from storage. If history exceeds `maxHistory`, older entries are trimmed during initialization. |
| `strictInitialPatches` | boolean | false | Whether invalid `initialPatches` should throw. When `false`, invalid patches are discarded and history starts empty. |
| `initialPosition` | number | 0 | Restore position when loading from storage. Invalid or out-of-range values are clamped after any history trimming. |
| `autoArchive` | boolean | true | Automatically save each change to history (see [Archive Mode](#archive-mode)). |
| `patchesOptions` | boolean | PatchesOptions | `true` (enable patches) | Customize JSON Patch format. Common options include `{ pathAsArray?: boolean, arrayLengthAssignment?: boolean }`. See [Mutative patches docs](https://mutative.js.org/docs/api-reference/create#patches). |
| `enableAutoFreeze` | boolean | false | Prevent accidental state mutations outside `set` ([learn more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options)). |
| `strict` | boolean | false | Enable stricter immutability checks ([learn more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options)). |
| `mark` | Mark[] | `() => void` | Mark certain objects as immutable ([learn more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options)). |
### Store Methods
#### `getControls()`
Returns a controls object with time-travel methods:
```typescript
const controls = useStore.getControls();
controls.back(amount?: number) // Go back in history
controls.forward(amount?: number) // Go forward in history
controls.go(position: number) // Go to specific position
controls.reset() // Reset to initial state
controls.rebase() // Clear history and make current state the new baseline
controls.canBack(): boolean // Check if can go back
controls.canForward(): boolean // Check if can go forward
controls.getHistory(): State[] // Get full history
controls.position: number // Current position
controls.patches: TravelPatches // Current patches
```
**Manual Archive Mode** (when `autoArchive: false`):
```typescript
// you can use type `StoreApi`, e.g. `controls as Controls,false>`
controls.archive() // Archive current changes
controls.canArchive(): boolean // Check if can archive
```
`getControls()` returns the underlying Travels controls object. It has a stable reference with live getters such as `position` and `patches`. Reading `controls.position` during render is fine, but do not expect the `controls` object identity itself to change for `useEffect` dependencies or `React.memo` props.
`controls.rebase()` is a destructive operation. It discards all undo/redo history and makes the current tracked state the new baseline. After rebasing, `controls.reset()` returns to that rebased snapshot, not the original initializer state. In manual archive mode, pending unarchived changes are included in the new baseline.
## Set Function Modes
The middleware supports four update styles. They are similar to Zustand at the call site, but the semantics are not identical in every case.
### 1. Mutation Style
```typescript
set((state) => {
state.count += 1;
state.nested.value = 'new';
});
```
Preferred for most updates, especially nested changes.
### 2. Shallow Merge Value
```typescript
set({ count: 5 });
```
Equivalent to a top-level `Object.assign(draft, partial)` merge into the tracked data state. This matches the common Zustand `set({ ... })` mental model for shallow updates.
### 3. Replace Value
```typescript
set({ count: 10, user: { name: 'Alice' } }, true);
```
Use `replace: true` when you intentionally want to replace the entire tracked data state, such as full rehydration.
### 4. Return Value Function
```typescript
set((state) => ({
...state,
count: state.count + 1,
}));
```
Function updaters are passed straight through to `travels.setState(...)`. If your function **returns an object**, that object becomes the next tracked data state. Unlike Zustand's common partial-update usage, this is **not** treated as a shallow merge. Return the complete next state object, or prefer mutation style / direct value merge.
### Recommended Usage
**Use mutation style (`set(fn)`) for most state updates**:
```typescript
// ✅ Recommended: clear intent, works well for nested updates
set((state) => {
state.count += 1;
state.user.name = 'Alice';
});
```
**Use direct value (`set({ ... })`) for shallow top-level merges:**
- Updating a few top-level fields
- Preserving standard Zustand ergonomics
- Simple persistence-related merges
```typescript
set({ count: 5, loading: false });
```
**Use `replace: true` for full replacement:**
- Restoring a full snapshot
- Resetting to a known complete data state
- Schema migrations that replace the whole tracked object
```typescript
// ✅ Full replacement
const loadFromStorage = () => {
const savedState = JSON.parse(localStorage.getItem('state'));
set(savedState, true); // Replace entire state
};
```
**Use return-value functions only when you are computing the entire next state:**
```typescript
// ✅ Safe: returns the full next tracked state
set((state) => ({
...state,
count: state.count + 1,
}));
```
```typescript
// ⚠️ Risky: siblings such as `user` will be dropped
set(() => ({ count: 10 }));
```
**Why mutation style is usually the best default:**
- **Clear semantics**: No ambiguity between shallow merge and full replacement
- **Nested updates stay ergonomic**: Update deep state without rebuilding objects
- **Patch history stays precise**: Only actual changed paths are recorded
- **Less footgun-prone**: Harder to accidentally replace sibling fields
## Archive Mode
### Auto Archive (default)
Every `set` call creates a new history entry:
```typescript
const useStore = create()(
travel((set) => ({
count: 0,
increment: () =>
set((state) => {
state.count += 1;
}),
}))
);
// Each call creates a history entry
increment(); // History: [0, 1]
increment(); // History: [0, 1, 2]
```
### Manual Archive
Group multiple changes into a single undo/redo step:
```typescript
const useStore = create()(
travel(
(set) => ({
count: 0,
increment: () =>
set((state) => {
state.count += 1;
}),
save: () => {
const controls = useStore.getControls();
if ('archive' in controls) {
controls.archive();
}
},
}),
{ autoArchive: false }
)
);
increment(); // Temporary change
increment(); // Temporary change
save(); // Archive as single entry
```
`controls.rebase()` is available in both archive modes. It is useful after loading or confirming a snapshot that should become the new reset target.
## Examples
### Complex State with Nested Updates
```typescript
type Todo = { id: number; text: string; done: boolean };
type State = {
todos: Todo[];
};
type Actions = {
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
removeTodo: (id: number) => void;
};
const useTodoStore = create()(
travel((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({
id: Date.now(),
text,
done: false,
});
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.done = !todo.done;
}
}),
removeTodo: (id) =>
set((state) => {
state.todos = state.todos.filter((t) => t.id !== id);
}),
}))
);
```
zustand-travel with other zustand middleware:
```ts
import { create } from 'zustand';
import { travel } from 'zustand-travel';
import { persist } from 'zustand/middleware';
type State = {
count: number;
};
type Actions = {
increment: (qty: number) => void;
decrement: (qty: number) => void;
};
export const useCountStore = create()(
travel(
persist(
(set) => ({
count: 0,
increment: (qty: number) =>
set((state) => {
state.count += qty;
}),
decrement: (qty: number) =>
set((state) => {
state.count -= qty;
}),
}),
{
name: 'counter',
}
)
)
);
```
### Using Controls in React
```tsx
function TodoApp() {
const { todos, addTodo, toggleTodo } = useTodoStore();
const controls = useTodoStore.getControls();
return (
controls.back()} disabled={!controls.canBack()}>
Undo
controls.forward()}
disabled={!controls.canForward()}
>
Redo
controls.reset()}>Reset
controls.rebase()}>Rebase
Position: {controls.position} / {controls.patches.patches.length}
);
}
```
If you pass `controls` through `React.memo` boundaries or use it directly as a `useEffect` / `useMemo` dependency, remember that `controls` itself is stable. Pass derived primitives such as `controls.position`, `controls.canBack()`, and `controls.canForward()` instead.
### Persistence
Persistence is a natural fit for initializing the store from a full snapshot:
```typescript
// Save state for persistence
const saveToStorage = () => {
const controls = useStore.getControls();
const state = useStore.getState();
localStorage.setItem('state', JSON.stringify(state));
localStorage.setItem('patches', JSON.stringify(controls.patches));
localStorage.setItem('position', JSON.stringify(controls.position));
};
// Load state on initialization
const loadFromStorage = () => {
const state = JSON.parse(localStorage.getItem('state') || '{}');
const patches = JSON.parse(
localStorage.getItem('patches') || '{"patches":[],"inversePatches":[]}'
);
const position = JSON.parse(localStorage.getItem('position') || '0');
return { state, patches, position };
};
const { state, patches, position } = loadFromStorage();
// ✅ Initialize the store from the persisted full data snapshot
const useStore = create()(
travel(() => state, {
initialPatches: patches,
initialPosition: position,
// Optional: strictInitialPatches: true,
})
);
```
**Note**: The initializer function `() => state` is called during setup with the `isInitializing` flag set to `true`, so it bypasses the travel tracking. This is the correct approach for setting initial state from persistence.
If persisted history is longer than `maxHistory`, Travels keeps only the most recent window and clamps `initialPosition` into that retained range during initialization.
If you later replace the live store from an out-of-band snapshot and want future `reset()` calls to return to that snapshot, do not call `useStore.setState(...)` directly. That bypasses `travels` history tracking. Route the snapshot through a store action that uses the middleware-provided `set(..., true)` and then call `rebase()`:
```typescript
type Actions = {
replaceFromSnapshot: (nextState: State) => void;
};
const useStore = create()(
travel((set) => ({
...state,
replaceFromSnapshot: (nextState) => {
set(nextState, true);
useStore.getControls().rebase();
},
}))
);
const hydrateFromServer = async () => {
const nextState = await fetch('/api/state').then((res) => res.json());
useStore.getState().replaceFromSnapshot(nextState);
};
```
## TypeScript Support
Full TypeScript support with type inference:
```typescript
import { create } from 'zustand';
import { travel } from 'zustand-travel';
type State = {
count: number;
user: { name: string; age: number };
};
type Actions = {
updateUser: (updates: Partial) => void;
};
const useStore = create()(
travel((set) => ({
count: 0,
user: { name: 'Alice', age: 30 },
updateUser: (updates) =>
set((state) => {
Object.assign(state.user, updates);
}),
}))
);
// Full type safety
const controls = useStore.getControls(); // Typed controls
const history = controls.getHistory(); // State[] with full types
controls.rebase(); // Typed and available on the returned controls
```
## How It Works
1. **Initialization Phase**:
- Use `isInitializing` flag to bypass travels during setup
- Call initializer to get initial state with actions
- Separate data state from action functions
2. **State Separation**:
- Only data properties are tracked by Travels
- Action functions are preserved separately
- The root store must be an object so data and actions can be separated
- Memory efficient: no functions in history
3. **Smart Updater Handling**:
- **Function mutations**: Pass directly to Travels and patch the draft
- **Returned values from functions**: Treat as the next full tracked data state
- **Values with `replace: true`**: Replace the tracked data state directly
- **Values without `replace`**: Convert to a shallow merge via `Object.assign`
4. **Bi-directional Sync**:
- User actions → `travelSet` → `travels.setState`
- Travels changes → merge state + actions → Zustand (complete replacement)
5. **Action Preservation**:
- Actions maintain stable references across undo/redo
- Always merged with state updates
## Performance
- **Efficient Storage**: Uses JSON Patches instead of full state snapshots
- **Fast Updates**: Powered by Mutative (10x faster than Immer)
- **Minimal Overhead**: Only tracks data changes, not functions
## Related
- [travels](https://github.com/mutativejs/travels) - Framework-agnostic undo/redo core
- [mutative](https://github.com/unadlib/mutative) - Efficient immutable updates
- [zustand](https://github.com/pmndrs/zustand) - Bear necessities for state management
## License
MIT