Repository navigation
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@2Highlights
- One core contract — built on
travels@3factory timelines (createTravels,restoreTravels,Travels.deserialize).use-travelno longer constructsnew Travels()internally. - Timeline-revision subscriptions —
useSyncExternalStorenow readstravels.getRevision(), soarchive(),rebase(), andgo()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
setStatecalls are allowed — the guard that threw whensetStateran 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.canRedoare 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.patchesis a cached read-only view instead of a fresh copy on every read,getHistory()returns the shared read-only view, and the newgetHistorySnapshot()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
exportsmap, ESM-first"type": "module",dist/index.cjs,travelsexternalized from the UMD bundle, productionprocess.env.NODE_ENVinlined, and@types/reactas 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 readAfter (v3):
const snapshot = controls.getHistorySnapshot(); // detached, caller-owned
const patches = travels.getPatches(); // detached copy from the storeUse 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.canArchiveexist only whenautoArchive: 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>andRebasableManualTravelsControls<S, P>no longer take the freeze parameterF.UseTravelOptionsomits onlymutable.
8. Package layout and module resolution
- The package is now
"type": "module"with an explicitexportsmap; deep imports such asuse-travel/dist/index.esm.jsare no longer resolvable. - The CommonJS entry was renamed from
dist/index.cjs.jstodist/index.cjs. - The UMD build externalizes
travels, so browser builds must provide theTravelsglobal alongsideReactandMutative. @types/reactmoved 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
setStatecalls within one event instead of throwingsetState 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.keysand object spread keep working. - Typed
devtoolsevents with the hook state type, so handlers no longer fall back toanyforevent.state. - Added a package-exports smoke test that renders from both the ESM and CJS entries.
- Corrected the documented
setStatecontract: 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, andUseTravelState. setStateis typed as the core setter (Travels<S>['setState']), so it accepts optionalTravelMetadataas its second argument.UseTravelState<S, F>keeps the deep read-only narrowing whenenableAutoFreezeis enabled (F = true).useTraveloverloads select the manual-archive controls contract fromautoArchive: false, soarchive()andcanArchive()type-check exactly when they exist at runtime.
Documentation
- New Upgrading to 3 section covering the core migration path.
- Documented
canUndo/canRedogetters vscanBack()/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
- Upgrade the core first:
pnpm add travels@3 mutative@2. - Replace
new Travels(state, options)withcreateTravels(state, options). - Remove
history,initialPatches,initialPosition, andstrictInitialPatchesfromuseTraveloptions. Restore withTravels.deserialize()+restoreTravels(), then subscribe withuseTravelStore(). - Remove
mutable,controlledApply,apply, andtargetfrom hook options. - Confirm you are on Node.js 22 or newer.
- Audit code that mutates
getHistory()/controls.patchesresults; switch togetHistorySnapshot()ortravels.getPatches(). - Update devtools persistence to listen to every event, not only
archive. - If you call
controls.archive()in automatic archive mode, move that work toautoArchive: 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.initialStateandoptionsare 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.enableAutoFreezestill typesstateas deeply read-only and makes external writes throw.
Links
Full Changelog: v2.2.0...v3.0.0