10x Faster Undo/Redo Using JSON Patches – Travels.js

Category: Javascript , Recommended | May 16, 2026
Authormutativejs
Last UpdateMay 16, 2026
LicenseMIT
Views34 views
10x Faster Undo/Redo Using JSON Patches – Travels.js

Travels is a framework-agnostic state history library that implements undo/redo functionality by storing JSON Patch differences instead of full state snapshots.

The library works with React, Vue, Zustand, or vanilla JavaScript and delivers 10x faster performance compared to traditional Immer-based approaches.

It’s ideal for building text editors, drawing applications, form builders, or any interactive apps where you need to reverse users’ actions.

Features:

  • Patch-based storage: Records only the differences between states using RFC 6902 JSON Patch format. Changing one field in a 1MB object stores just a few bytes.
  • Memory efficiency at scale: Traditional undo systems with 100 edits on a 1MB state object consume 100MB. Travels uses only kilobytes for the same history.
  • Fast immutable updates: Built on Mutative, which benchmarks 10x faster than Immer for draft-based state mutations.
  • Framework compatibility: Works as a standalone library or integrates with React (use-travel hook), Zustand (zustand-travel middleware), Vue, Pinia, and MobX.
  • Mutable mode support: Can mutate observable state in place for reactive frameworks that depend on reference stability.
  • Configurable history limits: Set maximum history entries with automatic pruning of oldest changes to control memory usage.

Use Cases:

  • Rich text editors: Track document changes, formatting operations, and content edits.
  • Visual design tools: Store canvas operations, layer modifications, and property changes in drawing apps or diagram editors where state objects grow large.
  • Form builders: Enable users to undo field additions, layout changes, or validation rule updates in complex form construction interfaces.
  • Collaborative apps: Maintain change history for conflict resolution and audit trails.

How To Use It:

1. Install Travels and its peer dependency Mutative using npm, yarn, or pnpm:

# Yarn
$ yarn add travels mutative
# NPM
$ npm install travels mutative
# PNPM
$ pnpm install travels mutative

2. Create a Travels instance with your initial state and start tracking changes:

import { createTravels } from 'travels';
// Initialize with starting state
const travels = createTravels({ count: 0 });
// Subscribe to state changes
// The listener receives current state, patches, and position
const unsubscribe = travels.subscribe((state, patches, position) => {
  console.log('Current state:', state);
  console.log('History position:', position);
});
// Update state using draft mutation (recommended approach)
travels.setState((draft) => {
  draft.count += 1; // Mutate the draft directly - Travels handles immutability
});
// Alternative: Set state directly with a new value
travels.setState({ count: 2 });
// Move backward in history
travels.back();
// Move forward in history
travels.forward();
// Retrieve current state
console.log(travels.getState()); // { count: 1 }
// Clean up subscription when done
unsubscribe();

3. Use Travels with React.

import { useSyncExternalStore } from 'react';
import { createTravels } from 'travels';
// Create Travels instance outside component
const travels = createTravels({ count: 0 });
function useTravel() {
  // Subscribe to state changes and trigger re-renders
  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>
      {/* Increment using draft mutation */}
      <button onClick={() => setState((draft) => { draft.count += 1; })}>
        Increment
      </button>
      {/* Undo button with disabled state */}
      <button onClick={() => controls.back()} disabled={!controls.canBack()}>
        Undo
      </button>
      {/* Redo button with disabled state */}
      <button onClick={() => controls.forward()} disabled={!controls.canForward()}>
        Redo
      </button>
    </div>
  );
}

4. Integrate Travels with Zustand for centralized state management with time-travel:

import { create } from 'zustand';
import { createTravels } from 'travels';
// Create Travels instance with initial state
const travels = createTravels({ count: 0 });
const useStore = create((set) => ({
  // Spread initial state into Zustand store
  ...travels.getState(),
  // Expose setState that updates both Travels and Zustand
  setState: (updater) => {travels.setState(updater);
    set(travels.getState());
  },
  // Expose controls for undo/redo operations
  controls: travels.getControls(),
}));
// Subscribe Travels changes to Zustand
travels.subscribe((state) => {
  useStore.setState(state);
});

5. Vue Composition API Integration:

import { ref, readonly } from 'vue';
import { createTravels } from 'travels';
export function useTravel(initialState, options) {
  // Create Travels instance
  const travels = createTravels(initialState, options);
  const state = ref(travels.getState());
  // Sync Travels state to Vue ref
  travels.subscribe((newState) => {
    state.value = newState;
  });
  // Wrapper for state updates
  const setState = (updater) => {
    travels.setState(updater);
  };
  return {
    state: readonly(state), // Prevent direct mutations
    setState,
    controls: travels.getControls(),
  };
}

6. Configuration options:

  • initialState (required): Your application’s starting state. Must be JSON-serializable (plain objects, arrays, primitives).
  • maxHistory (number, default: 10): Maximum number of history entries to keep. Older entries beyond this limit are automatically discarded.
  • initialPatches (TravelPatches, default: {patches: [], inversePatches: []}): Restore saved patches when loading from localStorage or other persistence.
  • initialPosition (number, default: 0): Restore position in history timeline when rehydrating from storage.
  • autoArchive (boolean, default: true): Automatically save each setState call to history. Set to false for manual control over history entries.
  • mutable (boolean, default: false): Mutate state in place instead of creating new objects. Required for observable frameworks like MobX, Vue, or Pinia that depend on reference stability.
  • patchesOptions (boolean | PatchesOptions, default: true): Customize JSON Patch format. Pass {pathAsArray: boolean} to control whether paths are arrays or strings.
  • enableAutoFreeze (boolean, default: false): Prevent accidental state mutations outside setState by freezing returned state objects in development.
  • strict (boolean, default: false): Enable stricter immutability checks for debugging mutation issues.
  • mark (Mark<O, F>[], default: undefined): Mark certain objects as immutable to skip processing them during updates.
const travels = createTravels({ count: 0 }, {
  // options here
});

7. API methods.

// Get current state without subscribing to changes
const currentState = travels.getState();
// Update state with direct value
travels.setState({ count: 5 });
// Update state with function that returns new value
travels.setState(() => ({ count: 5 }));
// Update state with draft mutation (recommended for performance)
travels.setState((draft) => {
  draft.count = 5;
  draft.items.push({ id: 1, text: 'New item' });
});
// Move back one step in history (undo)
travels.back();
// Move back multiple steps
travels.back(3);
// Move forward one step in history (redo)
travels.forward();
// Move forward multiple steps
travels.forward(2);
// Jump to specific position in timeline
travels.go(5); // Go to position 5
// Reset to initial state and clear all history
travels.rebase();
// Remove all past and future history and make the current state as the new initial state
travels.reset();
// Get complete history as read-only array
const history = travels.getHistory();
// WARNING: Do not modify this array - it's cached internally
// Get current position in timeline (0 = initial state)
const position = travels.getPosition();
// Get stored patches (JSON Patch differences)
const patches = travels.getPatches();
// Check if undo is available
const canUndo = travels.canBack();
// Check if redo is available
const canRedo = travels.canForward();
// Manual archive mode only: Save current state to history
travels.archive();
// Manual archive mode only: Check if there are unsaved changes
const hasUnsavedChanges = travels.canArchive();
// Get controls object for passing to UI components
// Contains all navigation methods and current state
const controls = travels.getControls();
controls.back();
controls.forward();
console.log(controls.position);

Advanced usages:

1. Optimize performance by skipping no-op updates:

const travels = createTravels({ count: 0, name: 'Test' });
// This produces no patches, so no history entry is created
travels.setState(state => state); // Position stays at 0
// Conditional update that changes nothing
travels.setState((draft) => {
  if (draft.count > 10) { // false, so draft is unchanged
    draft.count = 0;
  }
}); // Position still 0, no history entry
// Only actual changes create history entries
travels.setState((draft) => {
  draft.count = 5; // Real change
}); // Position moves to 1

2. Control when changes are recorded to history by setting autoArchive: false:

const travels = createTravels({ count: 0 }, { autoArchive: false });
// Multiple setState calls remain temporary
travels.setState({ count: 1 }); // Not in history yet
travels.setState({ count: 2 }); // Not in history yet
travels.setState({ count: 3 }); // Not in history yet
// Commit all changes as a single history entry
travels.archive(); // History: [0, 3]
// Undo goes back to 0, not 2 or 1
travels.back(); // Back to count: 0

3. Enable mutable: true when working with observable stores that require reference stability:

import { defineStore } from 'pinia';
import { reactive } from 'vue';
import { createTravels } from 'travels';
export const useTodosStore = defineStore('todos', () => {
  // Create reactive state first
  const state = reactive({ items: [] });
  
  // Pass reactive state to Travels with mutable mode
  const travels = createTravels(state, { mutable: true });
  const controls = travels.getControls();
  function addTodo(text) {
    // Travels mutates the reactive object in place
    travels.setState((draft) => {
      draft.items.push({ 
        id: crypto.randomUUID(), 
        text, 
        done: false 
      });
    });
  }
  return { state, addTodo, controls };
});

4. Save and restore state across browser sessions:

/ Save current state 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 and restore history
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'
  );
  // Create Travels with restored data
  return createTravels(initialState, {
    initialPatches,
    initialPosition,
  });
}
// Usage
const travels = loadFromStorage();
// User can now undo/redo previous session's changes

5. Wrap Travels methods to enforce business rules:

const travels = createTravels({ count: 0 });
const originalSetState = travels.setState.bind(travels);
// Add validation wrapper
travels.setState = function(updater) {
  // Validate direct object values
  if (typeof updater === 'object' && updater !== null) {
    if (updater.count < 0) {
      console.error('Count cannot be negative');
      return; // Block the operation
    }
    return originalSetState(updater);
  }
  // Wrap mutation functions to validate after execution
  if (typeof updater === 'function') {
    const wrappedUpdater = (draft) => {
      updater(draft); // Execute original mutation
      
      // Validate result
      if (draft.count < 0) {
        draft.count = 0; // Fix invalid state
        console.warn('Count was clamped to 0');
      }
    };
    return originalSetState(wrappedUpdater);
  }
  return originalSetState(updater);
};
// Now invalid updates are prevented
travels.setState({ count: -5 }); // Blocked
travels.setState((draft) => { draft.count = -10; }); // Clamped to 0

6. The maxHistory option controls memory usage by limiting stored patches:

const travels = createTravels({ count: 0 }, { maxHistory: 3 });
// Make 5 changes
for (let i = 1; i<=5; i++) {
  travels.setState((draft) => { draft.count += 1; });
}
// Current state: count = 5
// Position is capped at maxHistory (3)
// Available states in window: [2, 3, 4, 5]
// Why 4 states? 3 patches represent 3 transitions:
//   patch 0: 2>3
//   patch 1: 3>4
//   patch 2: 4>5
travels.back(); // count = 4
travels.back(); // count = 3
travels.back(); // count = 2 (window start)
console.log(travels.canBack()); // false - can't go further
// However, reset always returns to true initial state
travels.reset();
console.log(travels.getState().count); // 0

Alternatives:

  • Immer: The most popular immutable state library with produce() API.
  • Reddo.js: Framework-Agnostic Undo/Redo History Management Library.

FAQs:

Q: What happens if I exceed maxHistory with frequent updates?
A: Travels automatically discards the oldest patches when you exceed maxHistory. Your current position caps at maxHistory even if you make more changes. The reset() method can still return to the true initial state regardless of history trimming. For long-running applications, consider archiving old history to external storage using the subscribe callback.

Q: Why does mutable mode fail with Date objects and class instances?
A: Mutable mode relies on JSON serialization for the reset() operation via deepClone(initialState). Date objects convert to ISO strings. Class instances lose their methods and prototypes. Map and Set become empty objects. Store timestamps as numbers, serialize class data before storing, or use arrays instead of Set/Map to work around these limitations.

Q: How do I group multiple setState calls into a single undo step?
A: Set autoArchive: false when creating the Travels instance. Make your multiple setState calls. Then call travels.archive() to commit all changes as one history entry.

Q: Can I prevent certain state updates based on validation rules?
A: Yes. Wrap the setState method to add validation logic before execution. Save a reference to the original method with const original = travels.setState.bind(travels). Then reassign travels.setState to your wrapper function that checks conditions and either calls original(updater) or returns early to block the update.

Changelog:

v1.3.1 (05/16/2026)

  • Reuse a single cloned patches snapshot for subscribers and devtools callbacks during each change event.
  • Bugfixes.

v1.3.0 (05/16/2026)

  • Add versioned persistence APIs for serializing and restoring state, history, position, metadata, and schema version.
  • Add persistence migration hooks and compatibility checks for safely loading older or unsupported snapshots.
  • Add warnings for unsupported state shapes so applications can detect values that are unsafe for JSON Patch persistence.
  • Add product-oriented history controls for archive metadata, pending history entries, manual pending metadata, and status reporting.
  • Add integration examples for persistence adapters, local-first persistence, form builders, canvas editors, MobX, Pinia, Vue, and Zustand.
  • Add browser, property-based, persistence, product API, Vue example, and type-level test coverage.
  • Bugfixes

v1.2.0 (04/21/2026)

  • Added rebase()
  • rebasable controls via getControls()
  • dedicated tests covering normal, manual archive, mutable, and mid-history rebasing

You Might Be Interested In:


Leave a Reply