Skip to content

3.0.0

Latest

Choose a tag to compare

@unadlib unadlib released this 19 Oct 12:03

use-travel v3.0.0

React hooks for Travels, rebuilt on Travels 3 factory timelines.

use-travel@3 is a major release that aligns the React bindings with a single core contract. useTravel now owns new timelines only, everything you restore goes through Travels.deserialize() + restoreTravels(), and every history read is an explicit shared view or a detached snapshot. The hook now subscribes through the timeline revision, so archive, rebase, and navigation events that do not change the state reference still re-render your components.

npm install use-travel@3 travels@3 mutative@2

Highlights

  • One core contract — built on travels@3 factory timelines (createTravels, restoreTravels, Travels.deserialize). use-travel no longer constructs new Travels() internally.
  • Timeline-revision subscriptions — useSyncExternalStore now reads travels.getRevision(), so archive(), rebase(), and go() updates re-render even when the state object is unchanged. The mount subscription gap is closed: an update fired from a child's layout effect during mount is observed.
  • Sequential setState calls are allowed — the guard that threw when setState ran twice in one render cycle is gone. Synchronous calls are evaluated against the latest state.
  • Metadata on setState — setState(updater, metadata) forwards labels and other durable context to the history entry, matching core.
  • Render-safe undo/redo flags — controls.canUndo / controls.canRedo are frozen, enumerable, live getters that stay correct under the React Compiler; canBack() / canForward() remain for imperative use.
  • Stable, frozen controls — controls are Object.freezed and preserve the exact core capability surface (own enumerable properties included), so future Travels controls appear without a wrapper change.
  • Cheap render-time history — controls.patches is a cached read-only view instead of a fresh copy on every read, getHistory() returns the shared read-only view, and the new getHistorySnapshot() returns a detached, caller-owned array.
  • Predictable option validation — invalid option containers, removed restore options, and ownership options now throw with actionable messages instead of being silently dropped or overridden.
  • Modern packaging — explicit exports map, ESM-first "type": "module", dist/index.cjs, travels externalized from the UMD bundle, production process.env.NODE_ENV inlined, and @types/react as an optional peer.

Breaking changes

1. New peer requirements

Requirement v2.2.0 v3.0.0
travels ^2.2.0 ^3.0.0
mutative ^1.3.0 ^2.0.0
Node.js not declared >= 22
react ^17 || ^18 || ^19 ^17 || ^18 || ^19

Mutative 1.x is no longer supported. Install travels@3 and mutative@2 before upgrading this binding.

2. useTravel creates new timelines only

Restoring history through hook options is removed. Passing history, initialPatches, initialPosition, or strictInitialPatches now throws:

useTravel: history was removed; restore a complete snapshot with restoreTravels()
and pass that instance to useTravelStore()

Before (v2):

const [state, setState, controls] = useTravel(initialState, {
  history: persistedHistory,
  initialPosition: persistedPosition,
});

After (v3):

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

// Counter.tsx
const [state, setState, controls] = useTravelStore(travels);

Malformed snapshots now throw a TravelsPersistenceError instead of being silently discarded; pass a fallback snapshot when starting over is the intended recovery.

3. Ownership options are rejected, not overridden

useTravel always runs immutable and state-owning. It now throws for:

Option Message
mutable useTravel: the mutable option is not supported, because mutable mode cannot drive React updates
controlledApply useTravel: controlledApply is reserved for externally owned journals; use createTravelJournal()
apply Travels: apply is only available to externally owned journals (raised by core)
target Travels: target is only available when restoring a snapshot (raised by core)

Use createTravelJournal() from travels directly for externally owned journals, and plain travels when you need mutable: true.

4. Optional option containers are validated

Passing null, a number, a string, an array, or a function as the options argument now throws TypeError: useTravel: options must be a non-null, non-array object instead of being spread into the core options.

5. History reads are shared views

controls.getHistory() and controls.patches are cached, read-only views that keep their reference until history changes. Code that mutated the arrays returned by these members will now either fail or silently corrupt the shared cache.

Before (v2):

const history = controls.getHistory();
history.push(fakeEntry);          // was a fresh array
const patches = travels.getPatches();
patches.forEach(rewrite);         // was a fresh copy per read

After (v3):

const snapshot = controls.getHistorySnapshot(); // detached, caller-owned
const patches = travels.getPatches();           // detached copy from the store

Use getHistorySnapshot() when you need an array you can keep or mutate, and call getPatches() on the timeline (not controls.patches) for a patch copy.

6. Manual archive navigation emits a single go event

With autoArchive: false, navigating away with pending changes used to emit an archive event followed by a go. It now archives the pending work as part of a single go event.

devtools.mock.calls.map(([event]) => event.type);
// v2: ['archive', 'go']
// v3: ['go']

Persist from devtools on every event rather than only on archive.

7. Controls mirror the core contract

  • controls.archive / controls.canArchive exist only when autoArchive: false. In v2 they were always present, even in automatic mode.
  • Controls are frozen and expose the core members plus the two render-safe flags. Custom properties you attached to the controls object are no longer writable.
  • Control type parameters follow core: RebasableTravelsControls<S, P> and RebasableManualTravelsControls<S, P> no longer take the freeze parameter F. UseTravelOptions omits only mutable.

8. Package layout and module resolution

  • The package is now "type": "module" with an explicit exports map; deep imports such as use-travel/dist/index.esm.js are no longer resolvable.
  • The CommonJS entry was renamed from dist/index.cjs.js to dist/index.cjs.
  • The UMD build externalizes travels, so browser builds must provide the Travels global alongside React and Mutative.
  • @types/react moved to optional peer dependencies.

9. maxHistory: 0 is supported

0 is now a supported way to disable retained history (only the current state is kept). The previous development-only console error is gone; negative values surface Travels' own maxHistory must be non-negative error.


Fixes

  • Closed the subscription gap during mount, so an update fired from a child layout effect on the first render is no longer missed.
  • Re-render on timeline-only transitions (archive, rebase, go) that leave the state reference unchanged.
  • Allowed sequential setState calls within one event instead of throwing setState cannot be called multiple times in the same render cycle.
  • Preserved the complete core control surface (including getHistorySnapshot) on both hooks instead of hand-picking members.
  • Froze exposed controls and made their members own-enumerable, so Object.keys and object spread keep working.
  • Typed devtools events with the hook state type, so handlers no longer fall back to any for event.state.
  • Added a package-exports smoke test that renders from both the ESM and CJS entries.
  • Corrected the documented setState contract: it accepts a complete value, a function returning a value, or a draft mutation — not a partial state.

TypeScript

  • New public types: UseTravelOptions, UseTravelResult, UseTravelSetState, and UseTravelState.
  • setState is typed as the core setter (Travels<S>['setState']), so it accepts optional TravelMetadata as its second argument.
  • UseTravelState<S, F> keeps the deep read-only narrowing when enableAutoFreeze is enabled (F = true).
  • useTravel overloads select the manual-archive controls contract from autoArchive: false, so archive() and canArchive() type-check exactly when they exist at runtime.

Documentation

  • New Upgrading to 3 section covering the core migration path.
  • Documented canUndo / canRedo getters vs canBack() / canForward() methods, including React Compiler behavior.
  • Documented manual archive metadata and shared read-only history views.
  • Added guidance for recovering from malformed persistence snapshots.
  • Regenerated the API reference for Travels 3.

Upgrade checklist

  1. Upgrade the core first: pnpm add travels@3 mutative@2.
  2. Replace new Travels(state, options) with createTravels(state, options).
  3. Remove history, initialPatches, initialPosition, and strictInitialPatches from useTravel options. Restore with Travels.deserialize() + restoreTravels(), then subscribe with useTravelStore().
  4. Remove mutable, controlledApply, apply, and target from hook options.
  5. Confirm you are on Node.js 22 or newer.
  6. Audit code that mutates getHistory() / controls.patches results; switch to getHistorySnapshot() or travels.getPatches().
  7. Update devtools persistence to listen to every event, not only archive.
  8. If you call controls.archive() in automatic archive mode, move that work to autoArchive: false — the method is no longer present at runtime.

See the Travels 3 migration guide for every core change.

What's unchanged

  • useTravel(initialState, options?) still returns [state, setState, controls].
  • useTravelStore(travels) still rejects mutable instances and does not own the store lifecycle.
  • initialState and options are still read once per mounted component.
  • No-op updates still create no history entries.
  • rebase(), manual archive modes, metadata-aware history, and structured warnings behave as before.
  • enableAutoFreeze still types state as deeply read-only and makes external writes throw.

Links

Full Changelog: v2.2.0...v3.0.0