
Travels.js is a framework-agnostic JavaScript library for undo, redo, and time-travel state history.
It records forward and inverse JSON Patches. For large states with small edits, this can use less storage than full snapshots.
Travels can own state updates or record commits from an existing store. Editors and form builders can group related changes into undo steps and restore saved history across sessions.
Features:
- Stores forward and inverse JSON Patches for undo and redo history.
- Jumps to any retained history position and limits history size.
- Groups related state updates into one undo step through transactions or manual archiving.
- Serializes state, cursor position, patches, and metadata for persistent history.
- Validates restored history before replaying changes from storage.
- Exposes read-only timeline views and detached history snapshots.
- Supports mutable state updates for observable objects and immutable updates by default.
- Records externally generated patches through a controlled journal API.
Use Cases:
- Document editors with labeled undo commands, long editing sessions, and saved history.
- Canvas and diagram editors that track changes to layers, shapes, and properties.
- Form builders that group a series of field edits into one undo operation.
- Applications with an existing state manager that need an independent patch history journal.
How To Use It:
Installation
Travels 3 requires mutative@^2.0.0. Install the two packages together:
npm install travels@3 mutative@2 # or pnpm add travels@3 mutative@2 # or yarn add travels@3 mutative@2
Basic Undo and Redo
Create a history instance from JSON-compatible state. The subscriber receives one event object containing the current state, operation type, cursor position, and history length.
import { createTravels } from 'travels';
const travels = createTravels({ count: 0 });
const unsubscribe = travels.subscribe(({ type, state, position, historyLength }) => {
console.log(type, state.count, position, historyLength);
});
travels.setState((draft) => {
draft.count += 1;
});
travels.setState((draft) => {
draft.count += 1;
});
travels.back();
console.log(travels.getState().count); // 1
travels.forward();
console.log(travels.getState().count); // 2
unsubscribe();Group Related Updates
Use transaction() for one undo step that contains several synchronous updates. Travels publishes one final transaction event after a successful commit.
const travels = createTravels({ x: 0, y: 0 });
travels.transaction({ label: 'Move selection' }, () => {
travels.setState((draft) => { draft.x += 10; });
travels.setState((draft) => { draft.y += 20; });
});
travels.back(); // Both coordinates return to 0.Manual Archive Mode
For edits that must stay pending until a form blur or save action, choose autoArchive: false when creating the timeline. The archive() and canArchive() methods exist only in this mode.
const travels = createTravels({ title: '' }, { autoArchive: false });
travels.setState((draft) => { draft.title = 'Draft'; });
travels.setState((draft) => { draft.title = 'Final draft'; });
if (travels.canArchive()) {
travels.archive({ label: 'Edit title' });
}
travels.back(); // Restores the empty title.Save and Restore Undo History
Use serialize() to save a complete snapshot, including the history baseline and cursor. On load, validate the stored data with Travels.deserialize() and recreate the timeline with restoreTravels(). Use semantic validation for untrusted storage and keep state and metadata JSON-compatible. This replaces the older initialPatches and initialPosition configuration.
import { createTravels, restoreTravels, Travels } from 'travels';
const initialState = { title: '', blocks: [] };
const storageKey = 'editor-history';
function createEditorHistory() {
const saved = localStorage.getItem(storageKey);
if (!saved) return createTravels(initialState, { maxHistory: 50 });
const snapshot = Travels.deserialize(saved, {
validation: 'semantic',
fallback: {
version: 1,
state: initialState,
patches: { patches: [], inversePatches: [] },
position: 0,
},
});
return restoreTravels(snapshot, { maxHistory: 50 });
}
const editorHistory = createEditorHistory();
editorHistory.subscribe(() => {
localStorage.setItem(storageKey, JSON.stringify(editorHistory.serialize({ strict: true })));
});React Integration
A React component can subscribe to getRevision() through useSyncExternalStore(). The revision changes for timeline-only operations too, including rebase(). The example uses the Travels core directly.
import { useSyncExternalStore } from 'react';
import { createTravels } from 'travels';
const travels = createTravels({ count: 0 });
function Counter() {
useSyncExternalStore(travels.subscribe, travels.getRevision);
const state = travels.getState();
const controls = travels.getControls();
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => travels.setState(draft => { draft.count++; })}>
Increment
</button>
<button disabled={!controls.canBack()} onClick={() => controls.back()}>
Undo
</button>
<button disabled={!controls.canForward()} onClick={() => controls.forward()}>
Redo
</button>
</div>
);
}Zustand Integration
For a basic Zustand store, subscribe to Travels events and copy the new state into Zustand. Keep navigation controls outside the undoable state fields.
import { create } from 'zustand';
import { createTravels } from 'travels';
const travels = createTravels({ count: 0 });
const useCounter = create(() => ({
count: travels.getState().count,
increment: () => travels.setState(draft => { draft.count++; }),
undo: () => travels.back(),
redo: () => travels.forward(),
}));
travels.subscribe(({ state }) => {
useCounter.setState({ count: state.count });
});Vue Integration
Vue ref() can follow Travels events, including changes to undo and redo availability. For a reactive object that needs a stable identity, use mutable: true and pass that object to createTravels().
import { ref, readonly } from 'vue';
import { createTravels } from 'travels';
export function useEditorHistory(initialState) {
const travels = createTravels(initialState);
const state = ref(travels.getState());
const canUndo = ref(travels.canBack());
const canRedo = ref(travels.canForward());
const unsubscribe = travels.subscribe(({ state: nextState }) => {
state.value = nextState;
canUndo.value = travels.canBack();
canRedo.value = travels.canForward();
});
return {
state: readonly(state),
canUndo: readonly(canUndo),
canRedo: readonly(canRedo),
setState: (updater) => travels.setState(updater),
controls: travels.getControls(),
unsubscribe,
};
}TypeScript Usage
Travels includes TypeScript declarations. createTravels<State>() types state updates. createPatchableTravels() checks the JSON-shaped PatchableState contract at compile time and validates the initial value at runtime.
import { createTravels, createPatchableTravels, type JsonValue } from 'travels';
type EditorState = {
title: string;
blocks: Array<{ id: string; text: string }>;
};
const initialState: EditorState = { title: 'Untitled', blocks: [] };
const travels = createTravels<EditorState>(initialState);
travels.setState((draft) => {
draft.blocks.push({ id: 'intro', text: 'Hello' });
});
const durable = createPatchableTravels(initialState);
const jsonState: JsonValue = durable.getState();React and Zustand Wrapper Compatibility
The published [email protected] and [email protected] packages declare peer dependencies on Travels 2.x and Mutative 1.x. These releases cannot be installed as compatible wrappers for Travels 3. The React and Zustand examples here integrate directly with the travels core.
Controlled Journal for External State
An application that already owns state and produces forward and inverse patches can record its history through createTravelJournal(). Supply a synchronous apply() callback for navigation and commit state before calling recordPatches(). Do not record delegated navigation as a fresh commit. The journal does not expose setState(), transactions, tracking methods, or getControls(). For persisted journals use restoreTravelJournal(snapshot, { apply }).
import { apply as applyPatches, create } from 'mutative';
import { createTravelJournal } from 'travels';
let state = { count: 0 };
const journal = createTravelJournal(state, {
apply({ patches }) {
state = applyPatches(state, patches);
return state;
},
});
function increment() {
const [nextState, patches, inversePatches] = create(
state,
(draft) => { draft.count++; },
{ enablePatches: true }
);
state = nextState;
journal.recordPatches(state, { patches, inversePatches });
}
increment();
journal.back();Advanced Usage:
Skip No-Op Updates
An updater that leaves state unchanged produces no patch entry. Travels also avoids notifications for updates with no actual changes.
const travels = createTravels({ count: 0 });
travels.setState((draft) => {
if (draft.count > 10) draft.count = 0;
});
console.log(travels.getPosition()); // 0
travels.setState((draft) => { draft.count = 5; });
console.log(travels.getPosition()); // 1Limit Retained History
maxHistory counts retained patch transitions. After the limit is reached, undo stops at the oldest state in the current history window. reset() returns to the initial baseline.
const travels = createTravels({ count: 0 }, { maxHistory: 3 });
for (let i = 0; i < 5; i++) {
travels.setState((draft) => { draft.count += 1; });
}
travels.back(3);
console.log(travels.getState().count); // 2
console.log(travels.canBack()); // false
travels.reset();
console.log(travels.getState().count); // 0Keep Pinia State Reactive
For a Vue/Pinia store that must keep its observable root object, pass the reactive object to Travels with mutable: true. Do not set enableAutoFreeze: true in this mode.
import { defineStore } from 'pinia';
import { reactive } from 'vue';
import { createTravels } from 'travels';
export const useTasks = defineStore('tasks', () => {
const state = reactive({ items: [] });
const travels = createTravels(state, { mutable: true });
function addTask(text) {
travels.setState((draft) => {
draft.items.push({ id: crypto.randomUUID(), text });
});
}
return { state, addTask, history: travels.getControls() };
});Validate State Updates
Keep domain validation in application code before recording an edit. A dedicated update function can reject invalid changes before they enter Travels history.
const travels = createTravels({ count: 0 });
function setCount(value) {
if (!Number.isInteger(value) || value < 0) return false;
travels.setState((draft) => { draft.count = value; });
return true;
}
setCount(-5); // false
setCount(5); // trueConfiguration Options:
Pass the configuration object as the second argument of createTravels(initialState, options?). The initialState argument is required and should use the durable state types described later.
| Option | Type, Default, and Description |
|---|---|
maxHistory | number, default 10. Maximum retained patch entries. Accepts only non-negative integers. |
autoArchive | boolean, default true. Automatically archives each meaningful update. false exposes archive() and canArchive(). |
mutable | boolean, default false. Applies changes in place for observable object state. |
warnOnUnsupportedState | boolean, default true in development. Reports state, patch, and metadata persistence compatibility concerns. |
onError | (error) => void, default undefined. Receives wrapped core operation errors. |
onBranchDiscard | (event) => void, default undefined. Receives position and discarded history entries when a committed edit removes a redo branch. |
onWarning | (warning) => void, default undefined. Receives structured code and message warnings. |
onObserverError | (event) => void, default undefined. Receives observer failures with source and error. |
devtools | (event) => void, default undefined. Receives typed TravelsEvent notifications. |
patchesOptions | Mutative PatchesOptions, default {}. Accepts { pathAsArray: boolean }. Patch generation cannot be disabled. |
enableAutoFreeze | boolean, default false. Freezes states produced by later immutable updates. The original input object is not frozen automatically. Invalid together with mutable: true. |
strict | boolean, default false. Enables strict Mutative checks. |
mark | Mutative Mark configuration. Uses the Mutative default when omitted. Configures how Mutative recognizes immutable objects. |
API Reference:
State and Timeline Methods
| Method or Property | Description |
|---|---|
getState() | Returns the current state. Do not mutate the returned state directly. |
getRevision() | Returns an incrementing revision token for external-store subscriptions. |
setState(updater, metadata?) | Sets a state value, returns a state value from a function, or mutates a synchronous draft. Optional metadata labels the history entry. |
subscribe(listener) | Subscribes to committed timeline events through one event object and returns an unsubscribe function. |
back(amount?) | Moves backward by one entry by default, or by the given number of entries. |
forward(amount?) | Moves forward by one entry by default, or by the given number of entries. |
go(position) | Moves to a specific retained history cursor position. |
canBack() | Returns true when undo is available. |
canForward() | Returns true when redo is available. |
getPosition() | Returns the current cursor position. |
reset() | Restores the timeline baseline, cursor, and associated history captured when the instance was created or restored. |
rebase() | Clears past and future entries and makes the current state the new reset baseline. |
getHistory() | Returns the shared, cached read-only history array. |
getHistorySnapshot() | Returns detached, writable history state snapshots. Throws PERSISTENCE_INCOMPATIBLE if a state cannot be copied without changing its runtime type. |
getPatches() | Returns a detached snapshot of retained forward and inverse patches. |
getMetadata() | Returns metadata aligned with retained history entries and any pending manual entry. |
getHistoryEntries() | Returns history entries containing forward patches, inverse patches, and optional metadata. |
serialize(options?) | Returns a versioned state and history snapshot. { strict: true } validates JSON persistence compatibility. |
assertPersistenceCompatible() | Throws PERSISTENCE_INCOMPATIBLE for non-durable state, patches, paths, or metadata. |
transaction(metadata?, fn) | Records multiple synchronous operations as one history entry. |
batch(metadata?, fn) | Alias for transaction(). |
pauseTracking() | Temporarily stops recording history entries for new updates. |
resumeTracking() | Resumes tracking after a pause. |
replaceStateWithoutHistory(updater) | Applies a state replacement or mutation, clears history, and uses the result as the new baseline. |
getControls() | Returns a cached, frozen object exposing navigation and timeline views. |
mutable | Read-only boolean indicating the current immutable or mutable mode. |
archive(metadata?) | Manual archive mode only. Commits pending updates as one entry. |
canArchive() | Manual archive mode only. Checks for pending unarchived updates. |
Controls Object
getControls() returns read-only properties and functions. Manual-archive controls also expose the manual methods.
| Control | Description |
|---|---|
position | Current retained history cursor position. |
patches | Cached frozen ReadonlyTravelPatches view. Call travels.getPatches() for a detached copy. |
getHistory() | Returns a shared read-only history array. |
getHistorySnapshot() | Returns independent history state copies. |
back(amount?) | Undo one or more steps. |
forward(amount?) | Redo one or more steps. |
go(position) | Jump to a retained position. |
reset() | Restore the initial timeline baseline. |
rebase() | Make the current state the new baseline. |
canBack() | Check undo availability. |
canForward() | Check redo availability. |
archive(metadata?) | Available only for autoArchive: false. |
canArchive() | Available only for autoArchive: false. |
Factories, Persistence, and Validation Utilities
| Export | Description |
|---|---|
createTravels(initialState, options?) | Creates a state-owning undo/redo timeline. |
createPatchableTravels(initialState, options?) | Creates a state-owning timeline with a JSON-shaped state type constraint and runtime validation. |
restoreTravels(snapshot, options?) | Restores one complete TravelsSerializedHistory. Accepts creation options and optional target only with mutable: true. A reactive target must have the same plain-object or array root kind as the saved state. |
Travels.deserialize(snapshot, options?) | Validates a JSON string or object snapshot. Default validation is structural, with optional semantic replay. |
isTravels(value) | Type guard for state-owning Travels instances. External journals return false. |
createTravelJournal(initialState, { apply, ...options }) | Creates a journal for externally owned state. apply(transition) is required. Journal options accept the history, observer, and patch settings of ordinary timelines. The mutable option is unavailable for journals. |
restoreTravelJournal(snapshot, { apply, ...options }) | Restores a controlled journal from a complete snapshot. |
assertTravelHistoryEntryRecordable(entry) | Checks an external forward/inverse patch entry before the owner publishes it. Throws on invalid or unsafe entries. |
validateTravelHistoryEntry(entry) | Validates and normalizes a single forward/inverse patch entry. Returns an error or an accepted entry. |
validateTravelPatches(patches) | Validates and normalizes a patch-history container. Returns an error or accepted patches. |
findStateCompatibilityIssues(value, options?) | Reports state compatibility issues with code, path, and message. Options: maxIssues (number, default 20) and allowFrozen (boolean, default false). |
TRAVELS_HISTORY_SCHEMA_VERSION | Exported persistence schema version constant. |
TravelsPersistenceError | Error class used by history decoding and validation. |
TravelsError / TravelsTypeError | Typed runtime error classes for operation and configuration failures. |
History Deserialization Options
| Option | Description |
|---|---|
validation | structural (default) or semantic. Semantic mode replays retained changes to check reversibility. |
fallback | A fallback history snapshot or synchronous callback used when validation or decoding fails. |
migrate | A synchronous callback to migrate older saved history schemas. |
onError | Callback for parsing, schema, or migration failures. |
replayOptions | Mutative strict and mark settings used for semantic patch replay. |
Controlled Journal Methods
The returned TravelJournal facade is frozen. It shares history-read, subscription, persistence, revision, and navigation methods with ordinary timelines. It adds recordPatches() and delegates state changes to the external owner through apply().
| Journal API | Description |
|---|---|
recordPatches(state, entry) | Records a committed external state and matching patches, inversePatches, and optional metadata. |
apply(transition) | Required synchronous factory callback. Receives state, patches, inversePatches, fromPosition, and toPosition. Returns the externally committed state. |
back(), forward(), go() | Navigate history through the external owner callback. |
reset() | Asks the external owner to restore the journal baseline. |
rebase() | Clears retained history and keeps the current externally owned state. |
canBack(), canForward() | Reports undo and redo availability. |
getState(), getRevision(), getHistory(), getHistorySnapshot() | Reads current state, revision, cached history, or independent snapshots. |
getPosition(), getPatches(), getMetadata(), getHistoryEntries() | Reads the cursor, patch records, metadata, and history entries. |
subscribe() | Observes committed journal events, including recordPatches. |
serialize(), assertPersistenceCompatible() | Exports a journal snapshot or validates durable state and history. |
archive(), canArchive() | Available only when the journal uses autoArchive: false. |
Events and Callbacks
Subscribers and the devtools hook receive one TravelsEvent object. Event patch data describes the change for that notification, not the full retained history.
| Event Field | Description |
|---|---|
type | Operation name: setState, recordPatches (journals only), archive, transaction, go, reset, rebase, or replaceStateWithoutHistory. |
state | Read-only view of the current state. |
position | Current history cursor. |
historyLength | Number of retained history entries. |
patches | Read-only forward and inverse patch delta for the event. |
metadata | Optional metadata for the operation. |
Lifecycle and Error Callbacks
The lifecycle callbacks are onError(error), onObserverError({ source, error }), onBranchDiscard({ position, discarded }), and onWarning({ code, message }). The devtools(event) hook and subscribe(listener) use the event fields above. Subscriptions return an unsubscribe function.
The onWarning callback uses the codes HISTORY_DISABLED, HISTORY_DISCARDED, HISTORY_TRIMMED, INVALID_POSITION, MUTABLE_FALLBACK, MUTABLE_ROOT_REPLACEMENT, PERSISTENCE_COMPATIBILITY, and POSITION_CLAMPED. The event type union is open to future values. Use a default case in exhaustive event handlers.
Alternatives & Related Resources:
FAQs:
Q: Why does new Travels() or instanceof Travels fail in version 3?
A: Travels is a persistence namespace, not a constructor. Create a timeline with createTravels() and check an existing timeline with isTravels(value).
Q: Why does installing use-travel or zustand-travel produce a peer-dependency conflict?
A: Their published 2.2 releases require Travels 2.x and Mutative 1.x. The core examples above work directly with Travels 3. For a wrapper, select a release that explicitly accepts travels@^3.
Q: Why can undo after loading saved history throw HISTORY_REPLAY_FAILED?
A: Structural validation checks the snapshot format but does not replay patches. Use Travels.deserialize(saved, { validation: 'semantic' }) before restoring unverified data. Semantic validation can reject patch histories that cannot be replayed and reversed.
Q: Why does getHistorySnapshot() reject some state values?
A: The method cannot safely detach functions, accessors, class instances, and certain runtime collections while preserving their types. Normalize these values into plain data before recording history.
Q: Why does manual-mode navigation no longer emit a separate archive event?
A: Travels 3 commits pending manual changes and the cursor move as one go event. A persistence listener that saves only on archive misses that navigation. Save on each committed subscription event.
Changelog:
v3.0.1 (October 20, 2026)
- Bugfixes
v3.0.0 (October 19, 2026)
- Requires Mutative 2 and Node.js 22 or newer for Node.js use.
- Introduces dedicated restore factories, state-owning timelines, and controlled journal capabilities.
- Removes legacy history initialization options and direct
Travelsconstruction. - Returns read-only shared history views and introduces
getRevision()andisTravels(). - Restricts manual archive controls to manual archive instances.
v2.2.0 (July 26, 2026)
- Introduced
createPatchableTravels()and detachedgetHistorySnapshot()results. - Introduced structured warnings and stricter validation of externally supplied patch history.
v2.1.0 (July 19, 2026)
- Introduced
createTravelJournal()for history owned by another state manager. - Introduced journal
recordPatchesevents and external navigation callbacks.
v2.0.0 (July 18, 2026)
- Changed subscribers to a unified event object.
- Introduced optional semantic replay validation of stored histories.
v1.4.0 (July 12, 2026)
- Improved history consistency for manual changes, transactions, and mutable state.
- Rejected asynchronous update callbacks and invalid patch configurations.
v1.3.1 (May 16, 2026)
- Improved patch snapshot handling for subscriber and devtools callbacks.
v1.3.0 (May 15, 2026)
- Introduced versioned persistence and metadata-related history features.
v1.2.0 (April 22, 2026)
- Introduced
rebase()for changing the undo and reset baseline.







