# use-travel

![Node CI](https://github.com/mutativejs/use-travel/workflows/Node%20CI/badge.svg)
[![Coverage Status](https://coveralls.io/repos/github/mutativejs/use-travel/badge.svg?branch=main)](https://coveralls.io/github/mutativejs/use-travel?branch=main)
[![npm](https://img.shields.io/npm/v/use-travel.svg)](https://www.npmjs.com/package/use-travel)
![license](https://img.shields.io/npm/l/use-travel)

**React hooks for [Travels](https://github.com/mutativejs/travels): patch-based undo/redo state with immutable updates, manual archiving, rebasing, and shared-store support.**

`use-travel` is the React layer for [`travels`](https://github.com/mutativejs/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`](https://github.com/mutativejs/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?](#why-use-travel)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Choosing Between `useTravel`, `useTravelStore`, and `travels`](#choosing-between-usetravel-usetravelstore-and-travels)
- [API Reference](#api-reference)
  - [`useTravel(initialState, options?)`](#usetravelinitialstate-options)
  - [`useTravelStore(travels)`](#usetravelstoretravels)
- [Archive Modes](#archive-modes)
- [Important Behavior](#important-behavior)
- [Rebase](#rebase)
- [Persistence](#persistence)
- [State Requirements](#state-requirements)
- [Examples](#examples)
- [Related Projects](#related-projects)
- [License](#license)

## 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

```bash
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                                             |
| ---------- | --------------------------------------------------- |
| `1.8.1`    | `>= 1.3.1` (persistence and metadata APIs)          |
| `1.8.0`    | `>= 1.2.0` (required for `rebase` support)          |
| `< 1.8.0`  | `< 1.2.0`                                           |

## Quick Start

```tsx
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`](https://github.com/mutativejs/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:

```ts
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.

#### Options

| Option                   | Type              | Description                                                                       | Default                               |
| ------------------------ | ----------------- | --------------------------------------------------------------------------------- | ------------------------------------- |
| `maxHistory`             | `number`          | Maximum number of history entries to keep                                         | `10`                                  |
| `history`                | `TravelsHistory`  | Restore validated history returned by `Travels.deserialize(...)`                  | `undefined`                           |
| `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`                                |
| `warnOnUnsupportedState` | `boolean`         | Warn in development when state has weak JSON Patch 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`                           |
| `devtools`               | `(event) => void` | Observe core Travels events for custom devtools                                   | `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`                     | `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`              | 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()`    | `() => void`    | Commit the current working state as the next undo step |
| `controls.canArchive()` | `() => boolean` | Whether there are unarchived changes                   |

#### 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](https://react.dev/learn/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.

```tsx
// store.ts
import { Travels } from 'travels';

export const travels = new Travels({ count: 0 });
```

```tsx
// 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.

```tsx
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.

```tsx
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

### One `setState` call per synchronous call stack

`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.

```tsx
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.

### `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.

## 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

```tsx
const [state, setState, controls] = useTravel({ count: 0 });

setState((draft) => {
  draft.count = 5;
});

controls.rebase();

setState((draft) => {
  draft.count = 9;
});

controls.reset(); // { count: 5 }
```

## Persistence

`use-travel` re-exports `TravelPatches`, so you can persist both the current state and its history:

```tsx
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`:

```tsx
const [state, setState, controls] = useTravel(saved.state, {
  initialPatches: saved.patches,
  initialPosition: saved.position,
});
```

With `travels@1.3.1` or newer, you can also validate a versioned snapshot before passing it to the hook:

```tsx
import { Travels, type TravelsSerializedHistory } from 'travels';

const saved: TravelsSerializedHistory<{ count: number }> = {
  version: 1,
  state,
  patches: controls.patches,
  position: controls.position,
};

const history = Travels.deserialize(saved);

const [state, setState, controls] = useTravel(history.state, {
  history,
});
```

`history` overrides `initialPatches` and `initialPosition` when both forms are provided. If persisted patch data may be corrupt, set `strictInitialPatches: true` for legacy patch restores or validate snapshots with `Travels.deserialize(...)` before rendering.

## State Requirements

`use-travel` follows the same state rules as [`travels`](https://github.com/mutativejs/travels):

- Prefer plain JSON-serializable data.
- `Map` and `Set` are 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`.

## Examples

- [Basic](https://stackblitz.com/edit/react-xfw3uk?file=src%2FApp.js)
- [Manual Time Travel](https://stackblitz.com/edit/react-3mnzq9?file=src%2FApp.js)

## Related Projects

- [travels](https://github.com/mutativejs/travels) - The framework-agnostic undo/redo core
- [zustand-travel](https://github.com/mutativejs/zustand-travel) - Zustand middleware built on Travels

## License

`use-travel` is [MIT licensed](https://github.com/mutativejs/use-travel/blob/main/LICENSE).
