Travels
A fast, framework-agnostic undo/redo library that stores only changes, not full snapshots.
Travels gives your users the power to undo and redo their actions—essential for text editors, drawing apps, form builders, and any interactive application. Unlike traditional undo systems that copy entire state objects for each change, Travels stores only the differences (JSON Patches), making it 10x faster and far more memory-efficient.
Works with React, Vue, Zustand, or vanilla JavaScript.
Table of Contents
- Why Travels? Performance That Scales
- Installation
- Quick Start
- Core Concepts
- API Reference
- Mutable Mode: Keep Reactive State In Place
- Archive Mode: Control When Changes Are Saved
- State Requirements: JSON-Serializable Only
- Framework Integration
- Persistence: Saving History to Storage
- TypeScript Support
- Advanced: Extending Travels with Custom Logic
- Related Projects
- License
Why Travels? Performance That Scales
Traditional undo systems clone your entire state object for each change. If your state is 1MB and the user makes 100 edits, that's 100MB of memory. Travels stores only the differences between states (JSON Patches following RFC 6902), so that same 1MB object with 100 small edits might use just a few kilobytes.
Two key advantages:
-
Memory-efficient history storage - Stores only differences (patches), not full snapshots. Changing one field in a large object stores only a few bytes.
-
Fast immutable updates - Built on Mutative, which is 10x faster than Immer. Write simple mutation code like
draft.count++while maintaining immutability.
Framework-agnostic - Works with React, Vue, Zustand, MobX, Pinia, or vanilla JavaScript.
Installation
npm install travels mutative # or yarn add travels mutative # or pnpm add travels mutative
Integrations
- Zustand: zustand-travel - A powerful and high-performance time-travel middleware for Zustand
- React: use-travel - A React hook for state time travel with undo, redo, reset and archive functionalities.
Quick Start
import { createTravels } from 'travels'; // Create a travels instance with initial state const travels = createTravels({ count: 0 }); // Subscribe to state changes const unsubscribe = travels.subscribe((state, patches, position) => { console.log('State:', state); console.log('Position:', position); }); // Update state using mutation syntax (preferred - more intuitive) travels.setState((draft) => { draft.count += 1; // Mutate the draft directly }); // Or set state directly by providing a new value travels.setState({ count: 2 }); // Undo the last change travels.back(); // Redo the undone change travels.forward(); // Get current state console.log(travels.getState()); // { count: 1 } // Cleanup when done unsubscribe();
Try it yourself: Travels Counter Demo
Your state must be JSON-serializable (plain objects, arrays, strings, numbers, booleans, null) or Map/Set(Supported only in immutable mode; not supported in mutable mode.). Complex types like Date, class instances, and functions are not supported and may cause unexpected behavior. See State Requirements for details.
Core Concepts
Before diving into the API, understanding these terms will help:
State - Your application data. In the example above, { count: 0 } is the state.
Draft - A temporary mutable copy of your state that you can change freely. When you use setState((draft) => { draft.count++ }), the draft parameter is what you modify. Travels converts your mutations into immutable updates automatically.
Patches - The differences between states, stored as JSON Patch operations. Instead of saving entire state copies, Travels saves these small change records to minimize memory usage.
Position - Your current location in the history timeline. Position 0 is the initial state, position 1 is after the first change, etc. Moving back decreases position; moving forward increases it.
Archive - The act of saving the current state to history. By default, every setState call archives automatically. You can disable this and control archiving manually for more advanced use cases.
API Reference
createTravels(initialState, options?)
Creates a new Travels instance.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
initialState |
S | Your application's starting state (must be JSON-serializable) | (required) |
maxHistory |
number | Maximum number of history entries to keep. Older entries are dropped. | 10 |
initialPatches |
TravelPatches | Restore saved patches when loading from storage | {patches: [],inversePatches: []} |
initialPosition |
number | Restore position when loading from storage | 0 |
autoArchive |
boolean | Automatically save each change to history (see Archive Mode) | true |
mutable |
boolean | Whether to mutate the state in place (for observable state like MobX, Vue, Pinia) | false |
patchesOptions |
boolean | PatchesOptions | Customize JSON Patch format. Supports { pathAsArray: boolean } to control path format. See Mutative patches docs |
true (enable patches) |
enableAutoFreeze |
boolean | Prevent accidental state mutations outside setState (learn more) | false |
strict |
boolean | Enable stricter immutability checks (learn more) | false |
mark |
Mark<O, F>[] | Mark certain objects as immutable (learn more) | () => void |
Returns: Travels<S, F, A> - A Travels instance
Instance Methods
getState(): S
Get the current state.
setState(updater: S | (() => S) | ((draft: Draft<S>) => void)): void
Update the state. Supports three styles:
- Direct value:
setState({ count: 1 })- Replace state with a new object - Function returning value:
setState(() => ({ count: 1 }))- Compute new state - Draft mutation (recommended):
setState((draft) => { draft.count = 1 })- Mutate a draft copy
Performance Optimization: Updates that produce no actual changes (empty patches) won't create history entries or trigger subscribers. For example,
setState(state => state)or conditional updates that don't modify any fields. This prevents memory bloat from no-op operations.
subscribe(listener: (state, patches, position) => void): () => void
Subscribe to state changes. Returns an unsubscribe function.
Parameters:
listener: Callback function called on state changesstate: The new statepatches: The current patches historyposition: The current position in history
back(amount?: number): void
Undo one or more changes by moving back in history. Defaults to 1 step.
forward(amount?: number): void
Redo one or more changes by moving forward in history. Defaults to 1 step.
go(position: number): void
Jump to a specific position in the history timeline.
reset(): void
Reset to the initial state and clear all history.
getHistory(): readonly S[]
Returns the complete history of states as an array.
IMPORTANT: Do not modify the returned array. It is cached internally. In development mode, the array is frozen In production mode, modifications will corrupt the cache
getPosition(): number
Returns the current position in the history timeline.
getPatches(): TravelPatches
Returns the stored patches (the differences between states).
canBack(): boolean
Returns true if undo is possible (not at the beginning of history).
canForward(): boolean
Returns true if redo is possible (not at the end of history).
archive(): void (Manual archive mode only)
Saves the current state to history. Only available when autoArchive: false.
canArchive(): boolean (Manual archive mode only)
Returns true if there are unsaved changes that can be archived.
mutable: boolean
Returns whether mutable mode is enabled.
getControls(): TravelsControls | ManualTravelsControls
Returns a controls object containing all navigation methods and current state. Useful for passing to UI components without exposing the entire Travels instance.
const travels = createTravels({ count: 0 }); const controls = travels.getControls(); // Use controls controls.back(); controls.forward(); console.log(controls.position); console.log(controls.patches);
maxHistory option
The maxHistory option limits how many history entries (patches) are kept in memory. Older entries beyond this limit are automatically discarded to save memory.
How it works:
maxHistorydefines the maximum number of patches (changes), not states- When the limit is exceeded, the oldest patches are removed
- The current
positionis capped atmaxHistory, even if you make more changes reset()can always return to the true initial state, regardless of history trimming
Example: Understanding the history window
If you set maxHistory: 3 and make 5 increments, here's what happens:
const travels = createTravels({ count: 0 }, { maxHistory: 3 }); const controls = travels.getControls(); const increment = () => travels.setState((draft) => { draft.count += 1; }); // Make 5 changes increment(); // 1 increment(); // 2 increment(); // 3 increment(); // 4 increment(); // 5 expect(travels.getState().count).toBe(5); // Position is capped at maxHistory (3), so we're at position 3 // The library keeps only the last 3 patches, representing states: [2, 3, 4, 5] // Why 4 states? Because patches represent *transitions*: // - patch 0: 2→3 // - patch 1: 3→4 // - patch 2: 4→5 // So you can access 4 states total: the window start (2) plus 3 transitions // Go back 1 step: from 5 to 4 controls.back(); expect(travels.getPosition()).toBe(2); expect(travels.getState().count).toBe(4); // Go back 1 step: from 4 to 3 controls.back(); expect(travels.getPosition()).toBe(1); expect(travels.getState().count).toBe(3); // Go back 1 step: from 3 to 2 (the window start) controls.back(); expect(travels.getPosition()).toBe(0); expect(travels.getState().count).toBe(2); // Can only go back to the window start expect(controls.canBack()).toBe(false); // Can't go further back // However, reset() can still return to the true initial state controls.reset(); expect(travels.getState().count).toBe(0); // Back to the original initial state
Mutable Mode: Keep Reactive State In Place
mutable: true lets Travels mutate the same object reference you hand in. This is crucial for observable stores (MobX, Vue/Pinia, custom proxies) that depend on identity stability to trigger reactions. Under the hood, Travels still generates JSON Patches but applies them back to the live object via Mutative's apply(..., { mutable: true }), so undo/redo continues to work without allocating new objects.
When to Enable It
- You pass a reactive store into
createTravelsand swapping the reference would break your observers. - You expect subscribers (
travels.subscribe) to always receive the exact same object instance. - You batch multiple mutations with
autoArchive: falsebut still need the UI to reflect every intermediate change.
Stick with the default immutable mode for reducer-driven stores (Redux, Zustand) where replacing the root object is the norm.
Behavior at a Glance
setStatekeeps the reference stable as long as the current state root is an object. Primitive roots (number, string,null) trigger an automatic immutable fallback plus a dev warning.- No-op updates (producing empty patches) are optimized away and won't create history entries or notify subscribers.
back,forward, andgoalso mutate in place unless the history entry performs a root-level replacement (patch path[]). Those rare steps reassign the reference to keep history correct.resetreplays a diff from the original initial state, so the observable reference survives a reset.archive(manual mode) merges temporary patches and still mutates the live object before saving history.getHistory()reconstructs new objects from the stored patches. Treat them as read-only snapshots—they are not reactive proxies.subscribelisteners always receive the live mutable object, sostate === travels.getState()stays true.
Example: Pinia/Vue Store
import { defineStore } from 'pinia'; import { reactive } from 'vue'; import { createTravels } from 'travels'; export const useTodosStore = defineStore('todos', () => { const state = reactive({ items: [] }); const travels = createTravels(state, { mutable: true }); const controls = travels.getControls(); function addTodo(text: string) { travels.setState((draft) => { draft.items.push({ id: crypto.randomUUID(), text, done: false }); }); } return { state, addTodo, controls }; });
Vue components keep using the original state reference while Travels tracks history and provides controls for undo/redo.
Limitations & Tips
JSON Serialization Requirements:
The state must stay JSON-serializable because reset() relies on deepClone(initialState) for mutable mode. This has important implications:
-
❌ Date objects → Converted to ISO strings (not restored as Date)
{ createdAt: new Date(); } // Becomes: { createdAt: "2025-01-15T..." }
-
❌ Map and Set → Lost entirely (empty objects)
{ tags: new Set(['a', 'b']); } // Becomes: { tags: {} }
-
❌ undefined values → Removed from objects
{ name: 'Alice', age: undefined } // Becomes: { name: 'Alice' }
-
❌ Sparse arrays → Holes become
null[1, , 3]; // Becomes: [1, null, 3]
-
❌ Functions → Lost entirely
{ handler: () => {}; } // Becomes: { handler: undefined } → removed
-
❌ Circular references → Causes JSON.stringify error
const obj = { self: null }; obj.self = obj; // ❌ TypeError: Converting circular structure to JSON
-
❌ Class instances → Converted to plain objects (lose methods/prototype)
class User { getName() {} } { user: new User(); } // Becomes plain object without methods
Workarounds:
- Store timestamps as numbers:
{ createdAt: Date.now() } - Store Set/Map as arrays:
{ tags: ['a', 'b'] } - Avoid undefined—use
nullinstead - Serialize class instances before storing
- Break circular references or use a custom serialization strategy
Other Tips:
- If you often replace the entire root object (e.g.,
setState(() => newState)) the library has to fall back to immutable jumps when navigating history. Prefer mutating the provided draft to keep reference sharing. - You can inspect
travels.mutableat runtime to verify which mode is active. - See
docs/mutable-mode.mdfor a deep dive, integration checklists, and troubleshooting tips.
Archive Mode: Control When Changes Are Saved
Travels provides two ways to control when state changes are recorded in history:
Auto Archive Mode (default: autoArchive: true)
In auto archive mode, every setState call is automatically recorded as a separate history entry. This is the simplest mode and suitable for most use cases.
const travels = createTravels({ count: 0 }); // or explicitly: createTravels({ count: 0 }, { autoArchive: true }) // Each setState creates a new history entry travels.setState({ count: 1 }); // History: [0, 1], position: 1 travels.setState({ count: 2 }); // History: [0, 1, 2], position: 2 travels.setState({ count: 3 }); // History: [0, 1, 2, 3], position: 3 // No-op update - position stays the same (optimization) travels.setState(state => state); // History: [0, 1, 2, 3], position: 3 // Conditional update that changes nothing travels.setState(draft => { if (draft.count > 10) { // false, so no changes draft.count = 0; } }); // History: [0, 1, 2, 3], position: 3 travels.back(); // Go back to count: 2
Manual Archive Mode (autoArchive: false)
In manual archive mode, you control when state changes are recorded to history using the archive() function. This is useful when you want to group multiple state changes into a single undo/redo step.
Use Case 1: Batch multiple changes into one history entry
const travels = createTravels({ count: 0 }, { autoArchive: false }); // Multiple setState calls travels.setState({ count: 1 }); // Temporary change (not in history yet) travels.setState({ count: 2 }); // Temporary change (not in history yet) travels.setState({ count: 3 }); // Temporary change (not in history yet) // Commit all changes as a single history entry travels.archive(); // History: [0, 3] // Now undo will go back to 0, not 2 or 1 travels.back(); // Back to 0
Use Case 2: Explicit commit after a single change
function handleSave() { travels.setState((draft) => { draft.count += 1; }); travels.archive(); // Commit immediately }
Key Differences:
- Auto archive: Each
setState= one undo step - Manual archive:
archive()call = one undo step (can include multiplesetStatecalls)
State Requirements: JSON-Serializable Only
Travels stores and persists state using deepClone(...) internally. This makes reset and persistence fast and reliable, but only JSON-serializable values are preserved.
What works: Objects, arrays, numbers, strings, booleans,null, and Map/Set(Supported only in immutable mode; not supported in mutable mode.).
What doesn't work: Date, class instances, functions, or custom prototypes. These will either be converted (Date becomes an ISO string) or dropped entirely when history is reset or persisted.
Solution: Convert complex types to simple representations before storing. For example, store timestamps as numbers instead of Date objects, or store IDs that reference external data instead of storing class instances directly.
This limitation applies even with the mutable: true option.
Framework Integration
React Integration
import { useSyncExternalStore } from 'react'; import { createTravels } from 'travels'; const travels = createTravels({ count: 0 }); function useTravel() { const state = useSyncExternalStore( travels.subscribe.bind(travels), travels.getState.bind(travels) ); return [state, travels.setState.bind(travels), travels.getControls()] as const; } function Counter() { const [state, setState, controls] = useTravel(); return ( <div> <div>Count: {state.count}</div> <button onClick={() => setState((draft) => { draft.count += 1; })}> Increment </button> <button onClick={() => controls.back()} disabled={!controls.canBack()}> Undo </button> <button onClick={() => controls.forward()} disabled={!controls.canForward()}> Redo </button> </div> ); }
Zustand Integration
import { create } from 'zustand'; import { createTravels } from 'travels'; const travels = createTravels({ count: 0 }); const useStore = create((set) => ({ ...travels.getState(), setState: (updater) => { travels.setState(updater); set(travels.getState()); }, controls: travels.getControls(), })); // Subscribe to travels changes travels.subscribe((state) => { useStore.setState(state); });
Vue Integration
import { ref, readonly } from 'vue'; import { createTravels } from 'travels'; export function useTravel(initialState, options) { const travels = createTravels(initialState, options); const state = ref(travels.getState()); travels.subscribe((newState) => { state.value = newState; }); const setState = (updater) => { travels.setState(updater); }; return { state: readonly(state), setState, controls: travels.getControls(), }; }
Persistence: Saving History to Storage
To persist state across browser sessions or page reloads, save the current state, patches, and position. When reloading, pass these values as initialState, initialPatches, and initialPosition:
// Save to localStorage function saveToStorage(travels) { localStorage.setItem('state', JSON.stringify(travels.getState())); localStorage.setItem('patches', JSON.stringify(travels.getPatches())); localStorage.setItem('position', JSON.stringify(travels.getPosition())); } // Load from localStorage function loadFromStorage() { const initialState = JSON.parse(localStorage.getItem('state') || '{}'); const initialPatches = JSON.parse( localStorage.getItem('patches') || '{"patches":[],"inversePatches":[]}' ); const initialPosition = JSON.parse(localStorage.getItem('position') || '0'); return createTravels(initialState, { initialPatches, initialPosition, }); }
TypeScript Support
travels is written in TypeScript and provides full type definitions.
import { createTravels, type TravelsOptions, type TravelPatches, } from 'travels'; interface State { count: number; todos: Array<{ id: number; text: string }>; } const travels = createTravels<State>({ count: 0, todos: [] }); // Type-safe state updates travels.setState((draft) => { draft.count += 1; draft.todos.push({ id: 1, text: 'Buy milk' }); });
Advanced: Extending Travels with Custom Logic
You can enhance Travels by wrapping its methods to add validation, permissions, logging, rate limiting, and other custom behaviors.
Common use cases:
- ✅ Validation - Prevent invalid state changes before they're applied
- ✅ Permissions - Control who can undo/redo or modify state
- ✅ Logging & Auditing - Track all state changes for debugging or compliance
- ✅ Metadata - Automatically add timestamps, user IDs, or version numbers
- ✅ Rate Limiting - Throttle frequent updates to prevent performance issues
- ✅ History Overflow Detection - Archive old history to external storage
Quick example:
const travels = createTravels({ count: 0 }); const originalSetState = travels.setState.bind(travels); // Add validation travels.setState = function (updater: any) { if (typeof updater === 'object' && updater.count > 100) { console.error('Count cannot exceed 100'); return; // Block the operation } return originalSetState(updater); } as any;
📖 Full documentation: See Advanced Patterns Guide for:
- Complete examples with both direct values and mutation functions
- Composable wrapper patterns (validation, logging, permissions)
- Real-world integration patterns
- TypeScript-safe implementation techniques
Related Projects
- use-travel - React hook for time travel
- zustand-travel - Zustand middleware for time travel
- mutative - Efficient immutable updates
License
MIT