Mutable Mode Guide

January 23, 2026 · View on GitHub

Mutable mode makes Travels play nicely with observable state libraries (MobX, Vue's reactive, Pinia stores, etc.) that rely on stable object references to notify subscribers. Instead of replacing the entire state object on every update, Travels mutates the original object in place while still recording JSON Patches for undo/redo.

Use this guide to decide when to enable mutable: true, understand how it works internally, and apply it safely in production apps.

When You Should Enable Mutable Mode

  • You hand Travels the same object that your UI library observes and you cannot replace that reference without breaking reactivity (MobX stores, Vue/Pinia stores, custom proxies).
  • You want undo/redo without extra setState({...state}) copies or garbage-collection churn.
  • You batch updates with autoArchive: false but still expect the live store reference to update immediately.

Stick with the default immutable mode when you already replace references (React/Redux style reducers, Zustand, etc.) or when you prefer structural sharing for diffing.

Enabling Mutable Mode

import { createTravels } from 'travels';

const store = reactive({ count: 0 }); // Vue/Pinia example
const travels = createTravels(store, { mutable: true });

travels.setState((draft) => {
  draft.count += 1; // Mutates `store` in place
});

travels.getState() now always returns the same reference (store), but every mutation still produces patches so undo/redo keeps working.

How It Works Under the Hood

  1. createTravels deep clones the initial state once (via deepClone(initialState)) to keep a pristine copy for reset().
  2. Each setState call runs through Mutative's create(...) to generate patches/inverse patches. Those patches are immediately applied back to the live object via apply(..., { mutable: true }), so the reference never changes.
  3. Navigation commands (back, forward, go) reuse the stored patches. reset() instead computes a fresh diff back to the JSON-cloned initial snapshot so it can restore the original data shape without replaying every history step.
  4. If a history step replaces the entire root (patch path [] with op: 'replace'), Travels falls back to immutable assignment for that jump to guarantee correctness.

The full implementation lives in src/travels.ts and is exercised by test/mutable-mode.test.ts and test/primitive-edge-cases.test.ts.

Behavior by Operation

OperationReference preserved?Notes
setStateYes, as long as current state root is an objectNon-object roots (numbers, strings, null) trigger an automatic immutable fallback
back / forwardYes, unless the step applies a root-level replacementHappens when you undo a change that swapped the entire state object or type
go(position)Same as back/forwardTravels checks for root-replace patches before deciding whether it can mutate in place
archive (manual)YesTemporary patches are merged and applied to the same live object
resetUsuallyMutates in place only when both the current state and the stored initial snapshot are objects; otherwise it reassigns the root
getHistoryMixedIncludes the live state for the current position and cloned values for past/future steps
subscribeReceives the live (mutated) referenceSubscribers can safely compare by reference; values reflect the current undo position

Fallback & Safety Rails

  • Non-object roots: If the current state is a primitive or null, Travels logs a dev warning and behaves immutably for that update. Undo/redo still works—it just cannot mutate a primitive in place.
  • Root replacements in history: Navigating to a step that replaces the entire root (e.g., switching from {...} to [] or a primitive) forces a new reference for that jump only.
  • Function updaters returning a new root: In mutable mode, setState(() => newState) that replaces the root falls back to immutable assignment for that update (dev warning emitted).
  • Root array time travel: When the root state is an array, mutable back/forward/go can exhibit ordering limitations due to patch application semantics. If you rely on array-root navigation, use immutable mode or wrap the array in an object.
  • JSON-only data: Travels clones the initial state via deepClone(initialState) the moment you call createTravels. Any non-JSON values are therefore lost up front, and reset() simply copies from that sanitized snapshot. The same constraint applies regardless of mutable mode.
  • Draft best practices: Prefer mutating the provided draft (draft.count++) instead of returning a brand new object. Mutating drafts lets Travels keep using in-place patches during navigation.

Integration Patterns

MobX (simplified observable)

const mobxStore = makeAutoObservable({ todos: [] });
const travels = createTravels(mobxStore, { mutable: true });

autorun(() => {
  // mobxStore reference never changes
  console.log(mobxStore.todos.length);
});

function addTodo(title: string) {
  travels.setState((draft) => {
    draft.todos.push({ id: nanoid(), title, done: false });
  });
}

Vue / Pinia

export const useTodosStore = defineStore('todos', () => {
  const state = reactive({ items: [] });
  const travels = createTravels(state, { mutable: true });

  const controls = travels.getControls();

  function addTodo(text: string) {
    travels.setState((draft) => {
      draft.items.push({ id: crypto.randomUUID(), text, done: false });
    });
  }

  return { state, addTodo, travels, controls };
});

The reactive state reference is the same object that Vue components bind to, so they instantly see mutations while retaining undo/redo controls.

Manual Archive + Mutable

const travels = createTravels(store, { mutable: true, autoArchive: false });

function commitTransaction(cb: () => void) {
  cb();            // Run multiple travels.setState calls
  travels.archive(); // Save them as one undoable step
}

Because the state is mutated in place, your UI keeps updating during the transaction, but history only grows when you call archive().

Performance & Testing Notes

  • Mutable mode still generates JSON patches, so you can persist or inspect diffs just like immutable mode.
  • The dedicated test/mutable-mode.test.ts suite verifies reference stability across setState, back, forward, go, reset, archive, and subscribe.
  • test/bug-fixes.test.ts and test/coverage-improvements.test.ts include regression tests for resetting nested objects, deleting extra properties, and handling sparse arrays (mutable value updates fall back to immutable to preserve holes).

Troubleshooting Checklist

  1. Did the root reference change unexpectedly? Check whether that history step replaced the entire state (e.g., setState(() => newState) returning a fresh object). Prefer draft mutations to avoid this.
  2. Not seeing updates in manual archive mode? Remember that travels.setState still mutates the object immediately; archive() merely decides what becomes undoable.
  3. Seeing a warning about primitives? Ensure your root state is an object. You can wrap primitives ({ value: 0 }) if you need mutable semantics.
  4. Need to confirm mode at runtime? Inspect travels.mutable. It returns the current mode, which is handy for writing integration tests.

Summary

Mutable mode gives Travels the ergonomics of reactive stores without giving up its patch-based history. Enable it when you need reference stability, follow the JSON-only constraint, and prefer draft mutations to keep undo/redo fast and predictable.