Travels

May 10, 2026 · View on GitHub

Node CI npm license coverage bundle size

Patch-based undo/redo optimized for large state, small updates, long history, and persistence.

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 history much smaller to keep in memory and persist when updates touch a small part of a large state tree.

Works with React, Vue, Zustand, or vanilla JavaScript.

Table of Contents

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.

Travels is not designed to be the fastest possible choice for every hot path. Snapshot-based stacks can be faster for small state, short history, and local-only undo/redo because they avoid patch generation. Travels is designed for apps where history size, persistence cost, and replayable operation logs matter.

Core advantages:

  • Memory-efficient history storage - Stores only differences (patches), not full snapshots. Changing one field in a large object stores only a few bytes.

  • Persistence-friendly history - Patch histories are much smaller to serialize, store, send, and restore than full snapshot stacks when state is large and each update is small.

  • Fast immutable updates - Built on Mutative. Write simple mutation code like draft.count++ while maintaining immutability.

Framework-agnostic - Works with React, Vue, Zustand, MobX, Pinia, or vanilla JavaScript.

Choosing the Right Undo Strategy

ScenarioRecommended approach
Small state, short history, local-only undo/redoSnapshot stack, Redux-undo, or Zundo
Large state, small updates, long history, persistence, or operation logsTravels
Collaborative editing, conflict merging, or concurrent multi-user stateCRDT/OT system; Travels alone does not solve conflicts
PriorityTravels fitTrade-off
Minimize serialized history sizeStrongPatch history is compact for small changes to large state
Persist history to localStorage, IndexedDB, worker messages, or cloud syncStrongYou still need a storage and migration strategy
Lowest setState/undo/redo latency for tiny stateUsually not the best fitPatch generation and patch application add overhead
Large replace-everything updatesScenario-dependentLarge patches can approach snapshot costs
Framework integrationStrongUse immutable mode by default; use mutable mode for reactive stores that require identity stability

For current benchmark numbers and caveats, see benchmarks/README.md. The benchmark results intentionally separate hot-path latency from persistence costs because those trade-offs decide whether patch-based history is the right tool.

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


⚠️ Important: State Requirements

For persistence-safe history, keep state JSON-compatible: plain objects, arrays, strings, numbers, booleans, and null. Map/Set have limited runtime support in immutable mode, but need a custom codec for JSON persistence. Complex types like Date, class instances, DOM nodes, refs, and functions are not supported as durable state. 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:

ParameterTypeDescriptionDefault
initialStateSYour application's starting state (see state compatibility)(required)
maxHistorynumberMaximum number of history entries to keep. Older entries are dropped. Must be a non-negative integer (NaN, Infinity, decimals are rejected).10
initialPatchesTravelPatchesRestore saved patches when loading from storage{patches: [],inversePatches: []}
strictInitialPatchesbooleanWhether invalid initialPatches should throw. When false, invalid patches are discarded and history starts emptyfalse
initialPositionnumberRestore position when loading from storage0
historyTravelsHistoryRestore validated history returned by Travels.deserialize(...); overrides initialPatches and initialPositionundefined
autoArchivebooleanAutomatically save each change to history (see Archive Mode)true
mutablebooleanWhether to mutate the state in place (for observable state like MobX, Vue, Pinia)false
warnOnUnsupportedStatebooleanDevelopment warning for state values with weak patch/persistence semanticstrue in development
onErrorfunctionReceives typed TravelsError failures from core helper APIsundefined
onBranchDiscardfunctionCalled when a new edit after undo discards redo entriesundefined
devtoolsfunctionReceives timeline events for external devtools integrationsundefined
patchesOptionsboolean | PatchesOptionsCustomize JSON Patch format. Supports { pathAsArray: boolean } to control path format. See Mutative patches docstrue (enable patches)
enableAutoFreezebooleanPrevent accidental state mutations outside setState (learn more)false
strictbooleanEnable stricter immutability checks (learn more)false
markMark<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), metadata?): 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.

Pass optional metadata to label history entries for product UI:

travels.setState(
  (draft) => {
    draft.layer.name = 'Header';
  },
  { label: 'Rename Layer', source: 'layers-panel', timestamp: Date.now() }
);

subscribe(listener: (state, patches, position) => void): () => void

Subscribe to state changes. Returns an unsubscribe function.

Parameters:

  • listener: Callback function called on state changes
    • state: The new state
    • patches: The current patches history
    • position: 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.

rebase(): void

Remove all past and future history and make the current state as the new initial state.

Warning

This is a destructive operation. All previous and future history entries are discarded, and the current state (including any unarchived temp patches) becomes the new baseline (position 0). Any subsequent reset() calls will return to this new baseline, not the original initial state.

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).

getMetadata(): Array<TravelMetadata | undefined>

Returns metadata aligned with getPatches() entries, including the pending manual archive entry when one exists.

getHistoryEntries(): TravelHistoryEntry[]

Returns patch entries with inverse patches and optional metadata, using the same entry set as getPatches(). Use this for undo menus, devtools timelines, and audit views.

serialize(): TravelsSerializedHistory

Returns a versioned persistence snapshot containing the current state, patch history, and position. The returned state and patches are cloned so callers can safely pass the value to JSON.stringify, storage adapters, or compression.

Travels.deserialize(snapshot, options?): TravelsSerializedHistory

Validates and normalizes a persisted snapshot before restoring it with createTravels(..., { history }). Accepts either a parsed object or a JSON string. Invalid input throws TravelsPersistenceError unless a fallback is supplied.

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(metadata?): void (Manual archive mode only)

Saves the current state to history. Only available when autoArchive: false. Accepts optional metadata. If omitted, Travels uses the latest metadata supplied to pending setState(...) calls.

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.

transaction(metadata?, fn): void

Runs multiple setState calls and archives them as one undo step.

travels.transaction({ label: 'Move Selection' }, () => {
  travels.setState((draft) => {
    draft.selection.x += 10;
  });
  travels.setState((draft) => {
    draft.selection.y += 20;
  });
});

batch(...) is an alias for transaction(...).

pauseTracking(): void / resumeTracking(): void

Temporarily apply state updates without creating history entries. Paused updates become the new baseline so later undo/redo cannot replay patches against mismatched state.

replaceStateWithoutHistory(updater): void

Replace or mutate state without creating a history entry, then clear history and use the result as the new baseline. This is useful for loading server state, applying remote snapshots, or resetting external store data.

getControls(): RebasableTravelsControls | RebasableManualTravelsControls

Returns a controls object containing all navigation methods and current state, including rebase(). Useful for passing to UI components without exposing the entire Travels instance. The controls object is cached and should be treated as read-only (it is frozen in development).

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:

  • maxHistory defines the maximum number of patches (changes), not states
  • When the limit is exceeded, the oldest patches are removed
  • The current position is capped at maxHistory, even if you make more changes
  • reset() can always return to the true initial state, regardless of history trimming
  • Invalid values throw immediately: maxHistory must be a non-negative integer

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 createTravels and 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: false but 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

  • setState keeps 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.
  • Function updaters that return a brand-new root (root replacement) also fall back to immutable assignment in mutable mode, with a dev warning.
  • No-op updates (producing empty patches) are optimized away and won't create history entries or notify subscribers.
  • back, forward, and go also mutate in place unless the history entry performs a root-level replacement (patch path []). Those rare steps reassign the reference to keep history correct.
  • Root array time-travel in mutable mode can have ordering limitations; if you rely on array root navigation, prefer immutable mode or wrap the array in an object.
  • reset replays 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.
  • subscribe listeners always receive the live mutable object, so state === 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

Compatibility Requirements:

Mutable mode has the same durable-state requirements as immutable mode, plus a stricter rule for Map/Set: Map and Set are not supported in mutable mode because in-place patch application cannot reliably preserve their reactive semantics. See State Requirements and Compatibility for the full matrix.

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.mutable at runtime to verify which mode is active.
  • See docs/mutable-mode.md for 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 multiple setState calls)

State Requirements and Compatibility

Travels works best when state is durable data: plain objects, arrays, strings, numbers, booleans, and null. The patch engine can clone some richer JavaScript values, but JSON persistence and cross-environment replay only have predictable semantics for JSON-compatible data.

ValueImmutable runtimeMutable runtimeJSON persistenceRecommendation
Plain objectSupportedSupportedSupportedPreferred
ArraySupportedSupported, except sparse root array edge casesSupportedPreferred
string, number, boolean, nullSupportedFalls back to immutable for primitive rootsSupportedPreferred
undefinedPatchable in memoryPatchable in memoryRemoved from JSON objectsUse null
DateCloneable, but not durableCloneable, but not durableRestored as a string through JSONStore timestamp or ISO string
Map / SetRuntime support in immutable modeNot supportedRequires custom codecStore arrays, or provide a codec
Class instance / custom prototypeNot durableNot durableLoses prototype/methodsStore plain data or IDs
FunctionNot supportedNot supportedDropped by JSONKeep behavior outside state
Circular referenceNot supported for JSON persistenceNot supported for JSON persistenceJSON.stringify failsNormalize graph to IDs
DOM node, ref, observable instance bodyNot supported as durable stateNot supported as durable stateNot serializableStore outside Travels state
WeakMap / WeakSetNot supportedNot supportedNot serializableStore outside Travels state

TypeScript helpers are exported for users who want to enforce the durable subset in their own app code:

import { createTravels, type JsonValue, type PatchableState } from 'travels';

const initialDocumentState = {
  title: 'Draft',
  blocks: [] as Array<{ id: string; text: string }>,
} satisfies JsonValue;

const travels = createTravels(initialDocumentState);

function createHistoryFor<S extends PatchableState>(state: S) {
  return createTravels(state);
}

In development, Travels scans initial state and changed state for known compatibility hazards and logs warnings once per path. Disable those warnings with warnOnUnsupportedState: false when you intentionally provide custom codecs or non-persistent runtime-only values.

Framework Integration

Runnable and copyable integration examples live in examples/:

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>
  );
}

External Form Manager Integration

When a form manager or external store remains the single source of truth, Travels can stay as a pure history engine. In that setup, update Travels from the form layer with detached value snapshots, then apply undo/redo results back to the form by reading travels.getState() immediately after navigation. With autoArchive: false, you can decide when a set of form edits should become one undoable history step.

import { createTravels } from 'travels';

type FormValues = {
  title: string;
  description: string;
};

type FormApi<S> = {
  getValues: () => S;
  setValues: (values: S) => void;
};

const travels = createTravels<FormValues>(
  {
    title: '',
    description: '',
  },
  { autoArchive: false }
);

function bindHistoryToForm(form: FormApi<FormValues>) {
  const syncToHistory = () => {
    travels.setState(structuredClone(form.getValues()));
  };

  const commitHistoryStep = () => {
    if (travels.canArchive()) {
      travels.archive();
    }
  };

  const handleKeyDown = (event: KeyboardEvent) => {
    const modifier = event.metaKey || event.ctrlKey;

    if (modifier && event.key === 'z' && !event.shiftKey) {
      event.preventDefault();
      if (!travels.canBack()) return;
      travels.back();
      form.setValues(travels.getState());
      return;
    }

    if (
      (modifier && event.key === 'z' && event.shiftKey) ||
      (modifier && event.key === 'y')
    ) {
      event.preventDefault();
      if (!travels.canForward()) return;
      travels.forward();
      form.setValues(travels.getState());
    }
  };

  return {
    syncToHistory,
    commitHistoryStep,
    handleKeyDown,
  };
}

Call syncToHistory() whenever the form values change, and call commitHistoryStep() whenever your form layer considers those changes a single undoable step, for example on blur, submit, or a debounced commit.

getValues() should return a detached snapshot, not a live mutable reference owned by the form manager. If your form library returns live objects, clone them before passing them to Travels.

For react-hook-form, getValues() maps naturally to form.getValues(), and setValues(...) is typically implemented with form.reset(...).

This pattern is useful for integrations such as react-hook-form, custom form managers, or external stores where you want to avoid two reactive sources of truth. React can still render the form state, but Travels only records and replays history.

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, use the versioned snapshot API. A snapshot contains the current state, patch history, position, and schema version.

import {
  createTravels,
  Travels,
  TravelsPersistenceError,
} from 'travels';

function saveToStorage(travels) {
  localStorage.setItem('travels:document', JSON.stringify(travels.serialize()));
}

function loadFromStorage() {
  const stored = localStorage.getItem('travels:document');
  if (!stored) return createTravels(defaultState);

  const history = Travels.deserialize(stored, {
    fallback: {
      version: 1,
      state: defaultState,
      patches: { patches: [], inversePatches: [] },
      position: 0,
    },
    onError(error) {
      if (error instanceof TravelsPersistenceError) {
        console.warn('Ignoring invalid persisted history:', error.code);
      }
    },
  });

  return createTravels(history.state, {
    history,
    maxHistory: 50,
    strictInitialPatches: true,
  });
}

Travels.deserialize(...) validates:

  • schema version
  • snapshot shape
  • patch array shape
  • JSON Patch operation names and paths
  • position bounds

It throws TravelsPersistenceError with a stable code such as PARSE_ERROR, UNSUPPORTED_VERSION, INVALID_SCHEMA, INVALID_PATCHES, or MIGRATION_FAILED. Provide fallback when corrupted storage should recover to a known-safe snapshot instead of failing startup.

Use migrate to upgrade older snapshots before validation:

const history = Travels.deserialize(stored, {
  migrate(snapshot) {
    if (snapshot && typeof snapshot === 'object' && snapshot.version === 0) {
      return {
        version: 1,
        state: snapshot.state,
        patches: snapshot.history,
        position: snapshot.cursor,
      };
    }

    return snapshot;
  },
});

For larger histories, store JSON.stringify(travels.serialize()) in IndexedDB instead of localStorage. If storage size matters, compress the serialized string with a library such as lz-string before writing it, then decompress before calling Travels.deserialize(...).

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

Maintenance

License

MIT