Skip to content

About

A React hook for state time travel with undo, redo, reset and archive functionalities.

Topics

Resources

Stars

101 stars

Watchers

2 watching

Forks

Repository files navigation

use-travel

Node CI Coverage Status npm license

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:

  • useTravel for component-scoped state with undo/redo
  • useTravelStore for subscribing React components to an existing immutable Travels instance

Use plain travels directly when your state lives outside React, you need imperative reads right after navigation, or you need mutable: true.

Table of Contents

Why use-travel?

  • 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 += 1 while 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 Travels store with useTravelStore.

Installation

npm install use-travel travels mutative
# or
yarn add use-travel travels mutative
# or
pnpm add use-travel travels mutative

Version compatibility

use-travel travels
3.x ^3.0.0
2.2.x ^2.2.0
2.1.x ^2.1.0
2.0.x ^2.0.0
1.8.1–1.9.0 ^1.3.1 (persistence and metadata APIs)
1.8.0 >= 1.2.0 (required for rebase support)
< 1.8.0 < 1.2.0

use-travel@3 requires travels@3 so factory overloads, atomic restoration, read-only shared views, and controls always follow one core contract. Install the core major first, then upgrade this binding. Both require Node.js 22 or newer, and both declare mutative@^2.0.0 as their peer dependency.

Upgrading to 3

  • Replace new Travels(initialState, options) with createTravels(initialState, options) for shared stores.
  • useTravel now creates new timelines only. Remove history, initialPatches, initialPosition, and strictInitialPatches from its options.
  • Restore one complete snapshot with Travels.deserialize() and restoreTravels(), then subscribe with useTravelStore(). Malformed snapshots now throw instead of being discarded.
  • Upgrade mutative to 2 together with travels@3; Mutative 1.x is no longer supported.
  • Treat controls.getHistory() and controls.patches as shared, frozen views; controls.patches is no longer a fresh copy on every read. Use controls.getHistorySnapshot() for states you can change, and travels.getPatches() for a patch copy from a shared store.
  • With autoArchive: false, navigating with pending changes archives them in a single go event instead of an archive event followed by go. Persist from devtools on every event rather than only on archive.
  • See the Travels 3 migration guide for every core change.

Travels 2.2 adds structured warnings with stable codes. Pass onWarning to useTravel when you need to observe them instead of matching development-only console text.

Travels 2.1 adds controlled journals for integrations whose runtime already owns state. useTravel remains state-owning and rejects journal options such as apply; build those adapters with createTravelJournal(initialState, { apply }) from travels directly. Standard timelines used by these hooks never emit the new recordPatches event, but TravelsEvent['type'] is an open union, so devtools handlers should retain a fallback branch for event types added by minor Travels releases.

Quick Start

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.canUndo}>
        Undo
      </button>

      <button onClick={() => controls.forward()} disabled={!controls.canRedo}>
        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 })

Choosing Between useTravel, useTravelStore, and travels

  • Use useTravel when the state belongs to a React component and React should own the lifecycle.
  • Use useTravelStore when you already have a shared immutable Travels instance and want React to stay subscribed to it.
  • Use plain travels when another layer is the source of truth, you need imperative getState() reads after back() or forward(), or you need mutable: true.

API Reference

useTravel(initialState, options?)

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. It rejects mutable, the journal-only apply option, and the removed controlledApply option instead of silently overriding or dropping them. It creates a new timeline; use useTravelStore with restoreTravels() when restoring persistence.

Options

Option Type Description Default
maxHistory number Maximum number of history entries to keep 10
autoArchive boolean Save each change automatically or require manual archive() true
warnOnUnsupportedState boolean Warn in development about weak state, patch, metadata, or persistence semantics development only
onError (error) => void Receive wrapped Travels operation errors undefined
onBranchDiscard (event) => void Observe redo history discarded by a new edit undefined
onWarning (warning) => void Receive structured non-fatal Travels warnings undefined
onObserverError (event) => void Receive listener, devtools, and observer-hook failures undefined
devtools (event) => void Observe unified TravelsEvent timeline events undefined
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

Returns

Common tuple members:

Member Type Description
state UseTravelState<S, F> Current render snapshot
setState UseTravelSetState<S> Updates state with a value or draft function and optional entry metadata
controls.position number Current position in the history timeline
controls.getHistory() () => readonly ReadonlyState<S>[] Returns the shared, read-only history view
controls.getHistorySnapshot() () => S[] Returns a fully detached, caller-owned history snapshot
controls.patches ReadonlyTravelPatches Cached read-only view of 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 Imperative predicate — whether undo is possible (use in event handlers)
controls.canForward() () => boolean Imperative predicate — whether redo is possible (use in event handlers)
controls.canUndo boolean Render-safe reactive flag — whether undo is possible (read during render)
controls.canRedo boolean Render-safe reactive flag — whether redo is possible (read during render)

When autoArchive: false, the controls also include:

Member Type Description
controls.archive(metadata?) (metadata?: TravelMetadata) => void Commit the current working state as the next undo step
controls.canArchive() () => boolean Whether there are unarchived changes

The setter accepts the same optional metadata as Core, so labels and other durable context remain attached to the history entry:

setState(
  (draft) => {
    draft.title = 'Reviewed';
  },
  { label: 'review-title', source: 'editor' }
);

Checking undo/redo availability: getters vs methods

canUndo / canRedo are reactive getters and canBack() / canForward() are methods. They report the same thing, but are meant for different contexts:

  • During render (e.g. disabled={!controls.canUndo}), use the canUndo / canRedo getters. They are re-read on every render, so they stay correct — including under the React Compiler, which memoises method calls on the stable controls reference and would otherwise freeze controls.canBack() at its first-render value.
  • In event handlers or outside render (e.g. onClick={() => controls.canBack() && controls.back()}), the canBack() / canForward() methods are fine.

The methods are kept for backward compatibility and imperative use; the getters are the render-safe form.

useTravelStore(travels)

Subscribes React to an existing immutable Travels instance without creating a new store.

// store.ts
import { createTravels } from 'travels';

export const travels = createTravels({ 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.canUndo}>
        Undo
      </button>
    </div>
  );
}

Important notes for useTravelStore:

  • It only supports immutable Travels instances. Passing a store created with mutable: true throws.
  • It exposes the same navigation controls as useTravel, including rebase().
  • It is a React bridge, so the returned state is still a render snapshot.
  • If you need imperative "navigate and read immediately" behavior, call travels.back() or travels.forward() and read travels.getState() directly from the store.

Archive Modes

use-travel supports two recording modes.

Auto Archive Mode

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 }

Manual Archive Mode

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

Important Behavior

Sequential setState calls

useTravel evaluates every synchronous call against the latest state. Multiple calls in one event are supported; in automatic archive mode, every non-no-op call becomes its own undo step. Use one draft mutation when several changes should be undone together.

setState((draft) => {
  draft.count += 1;
  draft.todos.push({ id: 1, text: 'Buy milk' });
});

In manual archive mode, sequential calls remain pending until archive() groups them into one history entry.

initialState and options are read once

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.

No-op updates are ignored

Updates that do not produce actual changes do not create history entries.

Treat state as read-only

Change state only through setState and the controls. With Mutative 2, the objects of each new state are also the values of its history patches, so writing to state, or to a value kept from an earlier render, rewrites what redo replays. Enable enableAutoFreeze to make such writes throw and to type state as deeply read-only.

Rebase

controls.rebase() discards all past and future history and makes the current state the new baseline.

This is a destructive operation. After rebasing:

  • controls.position becomes 0
  • controls.getHistory() contains only the current state
  • controls.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 }

Persistence

Persist one complete, versioned snapshot from the underlying core instance. Restore and validate that snapshot before React subscribes, so state, cursor, patches, and metadata cross the boundary atomically:

// store.ts
import {
  Travels,
  createTravels,
  restoreTravels,
  type TravelsSerializedHistory,
} from 'travels';

type State = { count: number };

const raw = localStorage.getItem('travel-history');

export const travels = raw
  ? restoreTravels(Travels.deserialize<State>(raw, { validation: 'semantic' }))
  : createTravels<State>({ count: 0 });

export function saveTravels() {
  const snapshot: TravelsSerializedHistory<State> = travels.serialize({
    strict: true,
  });
  localStorage.setItem('travel-history', JSON.stringify(snapshot));
}
// Counter.tsx
import { useTravelStore } from 'use-travel';
import { travels } from './store';

const [state, setState, controls] = useTravelStore(travels);

Structural validation is the low-latency default. Select validation: 'semantic' for untrusted or potentially corrupted snapshots so every reachable history entry is replayed in both directions before restore.

A snapshot that fails validation makes Travels.deserialize() throw a TravelsPersistenceError. Pass a fallback snapshot when starting over is the intended recovery.

State Requirements

use-travel follows the same state rules as travels:

  • Use JSON-shaped state: plain objects, dense arrays, strings, finite numbers other than -0, booleans, and null.
  • Map and Set are rejected in both immutable and mutable modes. Normalize them to plain objects or dense arrays before creating the store.
  • 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.

Examples

Related Projects

License

use-travel is MIT licensed.

About

A React hook for state time travel with undo, redo, reset and archive functionalities.

Topics

Resources

Stars

101 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages