{"id":31938526,"url":"https://github.com/mutativejs/zustand-travel","last_synced_at":"2026-04-25T15:04:16.622Z","repository":{"id":317654970,"uuid":"1067329694","full_name":"mutativejs/zustand-travel","owner":"mutativejs","description":"A powerful and high-performance undo/redo middleware for Zustand with Travels","archived":false,"fork":false,"pushed_at":"2026-01-25T11:03:28.000Z","size":752,"stargazers_count":21,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-02-06T02:58:17.446Z","etag":null,"topics":["mutative","redo","time-travel","undo","undo-redo","zustand"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mutativejs.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-09-30T17:47:27.000Z","updated_at":"2026-02-04T21:26:17.000Z","dependencies_parsed_at":"2025-10-02T08:35:09.423Z","dependency_job_id":null,"html_url":"https://github.com/mutativejs/zustand-travel","commit_stats":null,"previous_names":["mutativejs/zustand-travel"],"tags_count":12,"template":false,"template_full_name":null,"purl":"pkg:github/mutativejs/zustand-travel","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mutativejs%2Fzustand-travel","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mutativejs%2Fzustand-travel/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mutativejs%2Fzustand-travel/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mutativejs%2Fzustand-travel/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mutativejs","download_url":"https://codeload.github.com/mutativejs/zustand-travel/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mutativejs%2Fzustand-travel/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32265988,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-25T09:15:33.318Z","status":"ssl_error","status_checked_at":"2026-04-25T09:15:31.997Z","response_time":59,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["mutative","redo","time-travel","undo","undo-redo","zustand"],"created_at":"2025-10-14T08:15:50.678Z","updated_at":"2026-04-25T15:04:16.616Z","avatar_url":"https://github.com/mutativejs.png","language":"TypeScript","readme":"# zustand-travel\n\n![Node CI](https://github.com/mutativejs/zustand-travel/workflows/Node%20CI/badge.svg)\n[![npm](https://img.shields.io/npm/v/zustand-travel.svg)](https://www.npmjs.com/package/zustand-travel)\n![license](https://img.shields.io/npm/l/zustand-travel)\n\nA powerful and high-performance undo/redo middleware for Zustand with [Travels](https://github.com/mutativejs/travels).\n\n## Features\n\n- ✨ **Time Travel**: Full undo/redo, reset, and rebase support for your Zustand stores\n- 🎯 **Mutation updates**: Write mutable code that produces immutable updates\n- 📦 **Lightweight**: Built on efficient JSON Patch storage\n- ⚡ **High Performance**: Powered by [Mutative](https://github.com/unadlib/mutative) (10x faster than Immer)\n- 🔧 **Configurable**: Customizable history size and archive modes\n- 🔄 **Reactive Controls**: Access time-travel controls anywhere\n\n## Installation\n\n```bash\nnpm install zustand-travel travels mutative zustand\n# or\nyarn add zustand-travel travels mutative zustand\n# or\npnpm add zustand-travel travels mutative zustand\n```\n\n### Version compatibility\n\n| zustand-travel | travels                                    |\n| -------------- | ------------------------------------------ |\n| `\u003e= 1.1.0`     | `\u003e= 1.2.0` (required for `rebase` support) |\n| `\u003c 1.1.0`      | `\u003c 1.2.0`                                  |\n\n## Quick Start\n\n```typescript\nimport { create } from 'zustand';\nimport { travel } from 'zustand-travel';\n\ntype State = {\n  count: number;\n};\n\ntype Actions = {\n  increment: (qty: number) =\u003e void;\n  decrement: (qty: number) =\u003e void;\n};\n\nexport const useCountStore = create\u003cState \u0026 Actions\u003e()(\n  travel((set) =\u003e ({\n    count: 0,\n    increment: (qty: number) =\u003e\n      set((state) =\u003e {\n        state.count += qty; // ⭐ Mutation style for efficient JSON Patches\n      }),\n    decrement: (qty: number) =\u003e\n      set((state) =\u003e {\n        state.count -= qty; // ⭐ Recommended approach\n      }),\n  }))\n);\n\n// Access controls\nconst controls = useCountStore.getControls();\ncontrols.back(); // Undo\ncontrols.forward(); // Redo\ncontrols.reset(); // Reset to initial state\ncontrols.rebase(); // Make the current state the new baseline\n```\n\nImportant behavior:\n\n- `travel(...)` expects the initializer to return an **object store**.\n- Only non-function fields are tracked in history. Action functions are preserved and reattached after undo/redo.\n- Plain serializable data is the safest default for persistence. If you persist complex values such as `Date`, `Map`, or `Set`, use a custom serialization strategy.\n\n## API\n\n### Middleware Options\n\n```typescript\ntravel(initializer, options?)\n```\n\nThe initial data state comes from `initializer`, not from `options`. `options` are forwarded to `Travels`, except `mutable`, which is intentionally disabled because Zustand already manages immutable store replacement.\n\n| Option                 | Type                      | Default                          | Description                                                                                                                                                                                               |\n| ---------------------- | ------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `maxHistory`           | number                    | 10                               | Maximum number of history entries to keep. Must be a non-negative integer. `0` disables undo/redo history.                                                                                                |\n| `initialPatches`       | TravelPatches             | {patches: [],inversePatches: []} | Restore saved patches when loading from storage. If history exceeds `maxHistory`, older entries are trimmed during initialization.                                                                        |\n| `strictInitialPatches` | boolean                   | false                            | Whether invalid `initialPatches` should throw. When `false`, invalid patches are discarded and history starts empty.                                                                                      |\n| `initialPosition`      | number                    | 0                                | Restore position when loading from storage. Invalid or out-of-range values are clamped after any history trimming.                                                                                        |\n| `autoArchive`          | boolean                   | true                             | Automatically save each change to history (see [Archive Mode](#archive-mode)).                                                                                                                            |\n| `patchesOptions`       | boolean ｜ PatchesOptions | `true` (enable patches)          | Customize JSON Patch format. Common options include `{ pathAsArray?: boolean, arrayLengthAssignment?: boolean }`. See [Mutative patches docs](https://mutative.js.org/docs/api-reference/create#patches). |\n| `enableAutoFreeze`     | boolean                   | false                            | Prevent accidental state mutations outside `set` ([learn more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options)).                                                           |\n| `strict`               | boolean                   | false                            | Enable stricter immutability checks ([learn more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options)).                                                                        |\n| `mark`                 | Mark\u003cO, F\u003e[]              | `() =\u003e void`                     | Mark certain objects as immutable ([learn more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options)).                                                                          |\n\n### Store Methods\n\n#### `getControls()`\n\nReturns a controls object with time-travel methods:\n\n```typescript\nconst controls = useStore.getControls();\n\ncontrols.back(amount?: number)      // Go back in history\ncontrols.forward(amount?: number)   // Go forward in history\ncontrols.go(position: number)       // Go to specific position\ncontrols.reset()                    // Reset to initial state\ncontrols.rebase()                   // Clear history and make current state the new baseline\ncontrols.canBack(): boolean         // Check if can go back\ncontrols.canForward(): boolean      // Check if can go forward\ncontrols.getHistory(): State[]      // Get full history\ncontrols.position: number           // Current position\ncontrols.patches: TravelPatches     // Current patches\n```\n\n**Manual Archive Mode** (when `autoArchive: false`):\n\n```typescript\n// you can use type `StoreApi`, e.g. `controls as Controls\u003cStoreApi\u003c{ count: number; }\u003e,false\u003e`\ncontrols.archive()                  // Archive current changes\ncontrols.canArchive(): boolean      // Check if can archive\n```\n\n`getControls()` returns the underlying Travels controls object. It has a stable reference with live getters such as `position` and `patches`. Reading `controls.position` during render is fine, but do not expect the `controls` object identity itself to change for `useEffect` dependencies or `React.memo` props.\n\n`controls.rebase()` is a destructive operation. It discards all undo/redo history and makes the current tracked state the new baseline. After rebasing, `controls.reset()` returns to that rebased snapshot, not the original initializer state. In manual archive mode, pending unarchived changes are included in the new baseline.\n\n## Set Function Modes\n\nThe middleware supports four update styles. They are similar to Zustand at the call site, but the semantics are not identical in every case.\n\n### 1. Mutation Style\n\n```typescript\nset((state) =\u003e {\n  state.count += 1;\n  state.nested.value = 'new';\n});\n```\n\nPreferred for most updates, especially nested changes.\n\n### 2. Shallow Merge Value\n\n```typescript\nset({ count: 5 });\n```\n\nEquivalent to a top-level `Object.assign(draft, partial)` merge into the tracked data state. This matches the common Zustand `set({ ... })` mental model for shallow updates.\n\n### 3. Replace Value\n\n```typescript\nset({ count: 10, user: { name: 'Alice' } }, true);\n```\n\nUse `replace: true` when you intentionally want to replace the entire tracked data state, such as full rehydration.\n\n### 4. Return Value Function\n\n```typescript\nset((state) =\u003e ({\n  ...state,\n  count: state.count + 1,\n}));\n```\n\nFunction updaters are passed straight through to `travels.setState(...)`. If your function **returns an object**, that object becomes the next tracked data state. Unlike Zustand's common partial-update usage, this is **not** treated as a shallow merge. Return the complete next state object, or prefer mutation style / direct value merge.\n\n### Recommended Usage\n\n**Use mutation style (`set(fn)`) for most state updates**:\n\n```typescript\n// ✅ Recommended: clear intent, works well for nested updates\nset((state) =\u003e {\n  state.count += 1;\n  state.user.name = 'Alice';\n});\n```\n\n**Use direct value (`set({ ... })`) for shallow top-level merges:**\n\n- Updating a few top-level fields\n- Preserving standard Zustand ergonomics\n- Simple persistence-related merges\n\n```typescript\nset({ count: 5, loading: false });\n```\n\n**Use `replace: true` for full replacement:**\n\n- Restoring a full snapshot\n- Resetting to a known complete data state\n- Schema migrations that replace the whole tracked object\n\n```typescript\n// ✅ Full replacement\nconst loadFromStorage = () =\u003e {\n  const savedState = JSON.parse(localStorage.getItem('state'));\n  set(savedState, true); // Replace entire state\n};\n```\n\n**Use return-value functions only when you are computing the entire next state:**\n\n```typescript\n// ✅ Safe: returns the full next tracked state\nset((state) =\u003e ({\n  ...state,\n  count: state.count + 1,\n}));\n```\n\n```typescript\n// ⚠️ Risky: siblings such as `user` will be dropped\nset(() =\u003e ({ count: 10 }));\n```\n\n**Why mutation style is usually the best default:**\n\n- **Clear semantics**: No ambiguity between shallow merge and full replacement\n- **Nested updates stay ergonomic**: Update deep state without rebuilding objects\n- **Patch history stays precise**: Only actual changed paths are recorded\n- **Less footgun-prone**: Harder to accidentally replace sibling fields\n\n## Archive Mode\n\n### Auto Archive (default)\n\nEvery `set` call creates a new history entry:\n\n```typescript\nconst useStore = create\u003cState\u003e()(\n  travel((set) =\u003e ({\n    count: 0,\n    increment: () =\u003e\n      set((state) =\u003e {\n        state.count += 1;\n      }),\n  }))\n);\n\n// Each call creates a history entry\nincrement(); // History: [0, 1]\nincrement(); // History: [0, 1, 2]\n```\n\n### Manual Archive\n\nGroup multiple changes into a single undo/redo step:\n\n```typescript\nconst useStore = create\u003cState\u003e()(\n  travel(\n    (set) =\u003e ({\n      count: 0,\n      increment: () =\u003e\n        set((state) =\u003e {\n          state.count += 1;\n        }),\n      save: () =\u003e {\n        const controls = useStore.getControls();\n        if ('archive' in controls) {\n          controls.archive();\n        }\n      },\n    }),\n    { autoArchive: false }\n  )\n);\n\nincrement(); // Temporary change\nincrement(); // Temporary change\nsave(); // Archive as single entry\n```\n\n`controls.rebase()` is available in both archive modes. It is useful after loading or confirming a snapshot that should become the new reset target.\n\n## Examples\n\n### Complex State with Nested Updates\n\n```typescript\ntype Todo = { id: number; text: string; done: boolean };\n\ntype State = {\n  todos: Todo[];\n};\n\ntype Actions = {\n  addTodo: (text: string) =\u003e void;\n  toggleTodo: (id: number) =\u003e void;\n  removeTodo: (id: number) =\u003e void;\n};\n\nconst useTodoStore = create\u003cState \u0026 Actions\u003e()(\n  travel((set) =\u003e ({\n    todos: [],\n    addTodo: (text) =\u003e\n      set((state) =\u003e {\n        state.todos.push({\n          id: Date.now(),\n          text,\n          done: false,\n        });\n      }),\n    toggleTodo: (id) =\u003e\n      set((state) =\u003e {\n        const todo = state.todos.find((t) =\u003e t.id === id);\n        if (todo) {\n          todo.done = !todo.done;\n        }\n      }),\n    removeTodo: (id) =\u003e\n      set((state) =\u003e {\n        state.todos = state.todos.filter((t) =\u003e t.id !== id);\n      }),\n  }))\n);\n```\n\nzustand-travel with other zustand middleware:\n\n```ts\nimport { create } from 'zustand';\nimport { travel } from 'zustand-travel';\nimport { persist } from 'zustand/middleware';\n\ntype State = {\n  count: number;\n};\n\ntype Actions = {\n  increment: (qty: number) =\u003e void;\n  decrement: (qty: number) =\u003e void;\n};\n\nexport const useCountStore = create\u003cState \u0026 Actions\u003e()(\n  travel(\n    persist(\n      (set) =\u003e ({\n        count: 0,\n        increment: (qty: number) =\u003e\n          set((state) =\u003e {\n            state.count += qty;\n          }),\n        decrement: (qty: number) =\u003e\n          set((state) =\u003e {\n            state.count -= qty;\n          }),\n      }),\n      {\n        name: 'counter',\n      }\n    )\n  )\n);\n```\n\n### Using Controls in React\n\n```tsx\nfunction TodoApp() {\n  const { todos, addTodo, toggleTodo } = useTodoStore();\n  const controls = useTodoStore.getControls();\n\n  return (\n    \u003cdiv\u003e\n      \u003cTodoList todos={todos} onToggle={toggleTodo} /\u003e\n\n      \u003cdiv className=\"controls\"\u003e\n        \u003cbutton onClick={() =\u003e controls.back()} disabled={!controls.canBack()}\u003e\n          Undo\n        \u003c/button\u003e\n        \u003cbutton\n          onClick={() =\u003e controls.forward()}\n          disabled={!controls.canForward()}\n        \u003e\n          Redo\n        \u003c/button\u003e\n        \u003cbutton onClick={() =\u003e controls.reset()}\u003eReset\u003c/button\u003e\n        \u003cbutton onClick={() =\u003e controls.rebase()}\u003eRebase\u003c/button\u003e\n      \u003c/div\u003e\n\n      \u003cdiv\u003e\n        Position: {controls.position} / {controls.patches.patches.length}\n      \u003c/div\u003e\n    \u003c/div\u003e\n  );\n}\n```\n\nIf you pass `controls` through `React.memo` boundaries or use it directly as a `useEffect` / `useMemo` dependency, remember that `controls` itself is stable. Pass derived primitives such as `controls.position`, `controls.canBack()`, and `controls.canForward()` instead.\n\n### Persistence\n\nPersistence is a natural fit for initializing the store from a full snapshot:\n\n```typescript\n// Save state for persistence\nconst saveToStorage = () =\u003e {\n  const controls = useStore.getControls();\n  const state = useStore.getState();\n\n  localStorage.setItem('state', JSON.stringify(state));\n  localStorage.setItem('patches', JSON.stringify(controls.patches));\n  localStorage.setItem('position', JSON.stringify(controls.position));\n};\n\n// Load state on initialization\nconst loadFromStorage = () =\u003e {\n  const state = JSON.parse(localStorage.getItem('state') || '{}');\n  const patches = JSON.parse(\n    localStorage.getItem('patches') || '{\"patches\":[],\"inversePatches\":[]}'\n  );\n  const position = JSON.parse(localStorage.getItem('position') || '0');\n\n  return { state, patches, position };\n};\n\nconst { state, patches, position } = loadFromStorage();\n\n// ✅ Initialize the store from the persisted full data snapshot\nconst useStore = create\u003cState\u003e()(\n  travel(() =\u003e state, {\n    initialPatches: patches,\n    initialPosition: position,\n    // Optional: strictInitialPatches: true,\n  })\n);\n```\n\n**Note**: The initializer function `() =\u003e state` is called during setup with the `isInitializing` flag set to `true`, so it bypasses the travel tracking. This is the correct approach for setting initial state from persistence.\n\nIf persisted history is longer than `maxHistory`, Travels keeps only the most recent window and clamps `initialPosition` into that retained range during initialization.\n\nIf you later replace the live store from an out-of-band snapshot and want future `reset()` calls to return to that snapshot, do not call `useStore.setState(...)` directly. That bypasses `travels` history tracking. Route the snapshot through a store action that uses the middleware-provided `set(..., true)` and then call `rebase()`:\n\n```typescript\ntype Actions = {\n  replaceFromSnapshot: (nextState: State) =\u003e void;\n};\n\nconst useStore = create\u003cState \u0026 Actions\u003e()(\n  travel((set) =\u003e ({\n    ...state,\n    replaceFromSnapshot: (nextState) =\u003e {\n      set(nextState, true);\n      useStore.getControls().rebase();\n    },\n  }))\n);\n\nconst hydrateFromServer = async () =\u003e {\n  const nextState = await fetch('/api/state').then((res) =\u003e res.json());\n\n  useStore.getState().replaceFromSnapshot(nextState);\n};\n```\n\n## TypeScript Support\n\nFull TypeScript support with type inference:\n\n```typescript\nimport { create } from 'zustand';\nimport { travel } from 'zustand-travel';\n\ntype State = {\n  count: number;\n  user: { name: string; age: number };\n};\n\ntype Actions = {\n  updateUser: (updates: Partial\u003cState['user']\u003e) =\u003e void;\n};\n\nconst useStore = create\u003cState \u0026 Actions\u003e()(\n  travel((set) =\u003e ({\n    count: 0,\n    user: { name: 'Alice', age: 30 },\n    updateUser: (updates) =\u003e\n      set((state) =\u003e {\n        Object.assign(state.user, updates);\n      }),\n  }))\n);\n\n// Full type safety\nconst controls = useStore.getControls(); // Typed controls\nconst history = controls.getHistory(); // State[] with full types\ncontrols.rebase(); // Typed and available on the returned controls\n```\n\n## How It Works\n\n1. **Initialization Phase**:\n   - Use `isInitializing` flag to bypass travels during setup\n   - Call initializer to get initial state with actions\n   - Separate data state from action functions\n\n2. **State Separation**:\n   - Only data properties are tracked by Travels\n   - Action functions are preserved separately\n   - The root store must be an object so data and actions can be separated\n   - Memory efficient: no functions in history\n\n3. **Smart Updater Handling**:\n   - **Function mutations**: Pass directly to Travels and patch the draft\n   - **Returned values from functions**: Treat as the next full tracked data state\n   - **Values with `replace: true`**: Replace the tracked data state directly\n   - **Values without `replace`**: Convert to a shallow merge via `Object.assign`\n\n4. **Bi-directional Sync**:\n   - User actions → `travelSet` → `travels.setState`\n   - Travels changes → merge state + actions → Zustand (complete replacement)\n\n5. **Action Preservation**:\n   - Actions maintain stable references across undo/redo\n   - Always merged with state updates\n\n## Performance\n\n- **Efficient Storage**: Uses JSON Patches instead of full state snapshots\n- **Fast Updates**: Powered by Mutative (10x faster than Immer)\n- **Minimal Overhead**: Only tracks data changes, not functions\n\n## Related\n\n- [travels](https://github.com/mutativejs/travels) - Framework-agnostic undo/redo core\n- [mutative](https://github.com/unadlib/mutative) - Efficient immutable updates\n- [zustand](https://github.com/pmndrs/zustand) - Bear necessities for state management\n\n## License\n\nMIT\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmutativejs%2Fzustand-travel","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmutativejs%2Fzustand-travel","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmutativejs%2Fzustand-travel/lists"}