React hooks for Travels: patch-based undo/redo state with immutable updates, manual archiving, rebasing, and shared-store support.
use-travel is the React layer for travels. It keeps the same core model as Travels, which stores JSON Patch history instead of full state snapshots, but exposes that model through React-friendly hooks:
useTravelfor component-scoped state with undo/redouseTravelStorefor subscribing React components to an existing immutableTravelsinstance
Use plain travels directly when your state lives outside React, you need imperative reads right after navigation, or you need mutable: true.
- Why use-travel?
- Installation
- Quick Start
- Choosing Between
useTravel,useTravelStore, andtravels - API Reference
- Archive Modes
- Important Behavior
- Rebase
- Persistence
- State Requirements
- Examples
- Related Projects
- License
- React-first API: Use a hook tuple instead of wiring subscriptions manually.
- Patch-based history: Undo/redo stores only changes, not full state snapshots.
- Mutative update syntax: Write
draft.count += 1while keeping immutable React state. - Manual archive mode: Group several edits into one undo step when needed.
- Rebase support: Promote the current state to the new reset baseline.
- Shared history support: Subscribe multiple React components to the same immutable
Travelsstore withuseTravelStore.
npm install use-travel travels mutative
# or
yarn add use-travel travels mutative
# or
pnpm add use-travel travels mutative| use-travel | travels |
|---|---|
>= 1.8.0 |
>= 1.2.0 (required for rebase support) |
< 1.8.0 |
< 1.2.0 |
import { useTravel } from 'use-travel';
export function Counter() {
const [state, setState, controls] = useTravel({ count: 0 });
return (
<div>
<strong>{state.count}</strong>
<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>
<button onClick={controls.reset}>Reset</button>
</div>
);
}setState supports three update styles:
- Direct value:
setState({ count: 1 }) - Function returning a value:
setState(() => ({ count: 1 })) - Draft mutation:
setState((draft) => { draft.count += 1 })
- Use
useTravelwhen the state belongs to a React component and React should own the lifecycle. - Use
useTravelStorewhen you already have a shared immutableTravelsinstance and want React to stay subscribed to it. - Use plain
travelswhen another layer is the source of truth, you need imperativegetState()reads afterback()orforward(), or you needmutable: true.
Creates a component-scoped immutable Travels instance and returns a tuple:
const [state, setState, controls] = useTravel(initialState, options);useTravel always uses immutable mode internally so React can observe state changes through reference updates. mutable is intentionally not supported here.
| Option | Type | Description | Default |
|---|---|---|---|
maxHistory |
number |
Maximum number of history entries to keep | 10 |
initialPatches |
TravelPatches |
Patch history to restore from persistence | { patches: [], inversePatches: [] } |
strictInitialPatches |
boolean |
Throw when persisted patches are invalid instead of falling back to empty history | false |
initialPosition |
number |
History position to restore from persistence | 0 |
autoArchive |
boolean |
Save each change automatically or require manual archive() |
true |
enableAutoFreeze |
boolean |
Forwarded to Mutative immutability options | false |
strict |
boolean |
Forwarded to Mutative strict immutability checks | false |
mark |
Mark<O, F>[] |
Forwarded to Mutative mark options | () => void |
patchesOptions |
PatchesOptions |
Customize patch output such as { pathAsArray: true } |
enabled |
Common tuple members:
| Member | Type | Description |
|---|---|---|
state |
Value<S, F> |
Current render snapshot |
setState |
Updater<S> |
Updates state with a value, function, or draft mutation |
controls.position |
number |
Current position in the history timeline |
controls.getHistory() |
() => Value<S, F>[] |
Returns the history as state snapshots |
controls.patches |
TravelPatches |
Returns the stored patch history |
controls.back(amount?) |
(amount?: number) => void |
Undo one or more steps |
controls.forward(amount?) |
(amount?: number) => void |
Redo one or more steps |
controls.go(position) |
(position: number) => void |
Jump to a specific history position |
controls.reset() |
() => void |
Reset to the initial state and clear history |
controls.rebase() |
() => void |
Make the current state the new baseline and discard past and future history |
controls.canBack() |
() => boolean |
Whether undo is possible |
controls.canForward() |
() => boolean |
Whether redo is possible |
When autoArchive: false, the controls also include:
| Member | Type | Description |
|---|---|---|
controls.archive() |
() => void |
Commit the current working state as the next undo step |
controls.canArchive() |
() => boolean |
Whether there are unarchived changes |
Subscribes React to an existing immutable Travels instance without creating a new store.
// store.ts
import { Travels } from 'travels';
export const travels = new Travels({ count: 0 });// Counter.tsx
import { useTravelStore } from 'use-travel';
import { travels } from './store';
export function Counter() {
const [state, setState, controls] = useTravelStore(travels);
return (
<div>
<span>{state.count}</span>
<button
onClick={() =>
setState((draft) => {
draft.count += 1;
})
}
>
Increment
</button>
<button onClick={() => controls.back()} disabled={!controls.canBack()}>
Undo
</button>
</div>
);
}Important notes for useTravelStore:
- It only supports immutable
Travelsinstances. Passing a store created withmutable: truethrows. - It exposes the same navigation controls as
useTravel, includingrebase(). - It is a React bridge, so the returned
stateis still a render snapshot. - If you need imperative "navigate and read immediately" behavior, call
travels.back()ortravels.forward()and readtravels.getState()directly from the store.
use-travel supports two recording modes.
With the default autoArchive: true, every setState call becomes its own undo step.
const [state, setState, controls] = useTravel({ count: 0 });
function increment() {
setState((draft) => {
draft.count += 1;
});
}
// Three separate user interactions:
// click #1 -> count = 1
// click #2 -> count = 2
// click #3 -> count = 3
controls.back(); // { count: 2 }With autoArchive: false, you decide when the current working state should become a committed history entry.
This is useful for flows like forms, drag interactions, or multi-step editors where several changes should undo together.
const [doc, setDoc, controls] = useTravel(
{ title: '', body: '' },
{ autoArchive: false }
);
function onTitleChange(title: string) {
setDoc((draft) => {
draft.title = title;
});
}
function onBodyChange(body: string) {
setDoc((draft) => {
draft.body = body;
});
}
function save() {
if (controls.canArchive()) {
controls.archive();
}
}useTravel throws if setState is called more than once within the same synchronous call stack. If multiple fields need to change together, update them in a single draft mutation.
setState((draft) => {
draft.count += 1;
draft.todos.push({ id: 1, text: 'Buy milk' });
});In manual archive mode, you can still make one setState call per event or render and archive later when the grouped change is ready.
useTravel creates the underlying Travels instance only on the first render. Later changes to initialState or options do not recreate the history store automatically. If you need a fresh store, remount the component or change its key.
Updates that do not produce actual changes do not create history entries.
controls.rebase() discards all past and future history and makes the current state the new baseline.
This is a destructive operation. After rebasing:
controls.positionbecomes0controls.getHistory()contains only the current statecontrols.reset()returns to the rebased state, not the original initial state- In manual archive mode, any unarchived working changes become part of the new baseline
const [state, setState, controls] = useTravel({ count: 0 });
setState((draft) => {
draft.count = 5;
});
controls.rebase();
setState((draft) => {
draft.count = 9;
});
controls.reset(); // { count: 5 }use-travel re-exports TravelPatches, so you can persist both the current state and its history:
import type { TravelPatches } from 'use-travel';
type SavedTravel = {
state: { count: number };
patches: TravelPatches;
position: number;
};
const saved: SavedTravel = {
state,
patches: controls.patches,
position: controls.position,
};Restore that data by passing the saved state as initialState and the saved history as initialPatches plus initialPosition:
const [state, setState, controls] = useTravel(saved.state, {
initialPatches: saved.patches,
initialPosition: saved.position,
});If persisted patch data may be corrupt, set strictInitialPatches: true to fail fast instead of silently starting with empty history.
use-travel follows the same state rules as travels:
- Prefer plain JSON-serializable data.
MapandSetare supported in immutable mode.- Avoid complex mutable objects such as class instances, functions, DOM nodes, or framework-specific reactive proxies.
If you need mutable observable state, use travels directly instead of useTravelStore.
- travels - The framework-agnostic undo/redo core
- zustand-travel - Zustand middleware built on Travels
use-travel is MIT licensed.