DEV Community

Michael Lin
Michael Lin

Posted on

Rethinking Undo/Redo - Why We Need Travels

TL;DR: If your app state is large (>10KB), needs persistence, or you care about memory usage and performance with long histories, Travels’ JSON Patch approach is worth considering. For simple scenarios, the default snapshot modes in Redux-undo or Zundo are actually simpler and more efficient.

Where the Problem Starts

Implementing usable undo/redo sounds easy: store a full snapshot for every state change and restore it on undo. The problem is that when the state object is large and changes are small (the usual case), snapshot memory = object size × history length. That linear growth is unacceptable for big objects. Worse, repeatedly deep-copying/patch-diffing complex structures pushes the main thread toward jank—this isn’t something you “optimize a bit” to fix; it’s a category error.

Travels starts by making JSON Patch (RFC 6902) a first-class citizen: generate patches/inversePatches incrementally as changes happen, trading compute for storage and serialization efficiency—preserving full reversibility while drastically shrinking history size.

Travels was created to resolve a fundamental tension: how to retain a complete history while minimizing memory and performance overhead.

How Mainstream Approaches Work

Let’s first look at how two popular community solutions work in practice.

Redux-undo: Reference-Based Snapshot Storage

Redux-undo is the classic solution in the Redux ecosystem. Its core logic looks like:

// redux-undo/src/reducer.js
function insert(history, state, limit, group) {
  const newPast = [...pastSliced, _latestUnfiltered];
  return newHistory(newPast, state, [], group);
}
Enter fullscreen mode Exit fullscreen mode

Its strategy: store references to each state object.

{
  past: [state1, state2, state3],    // array of object references
  present: state4,
  future: [state5, state6]
}
Enter fullscreen mode Exit fullscreen mode

There’s no deep copy here; it relies on Redux’s immutable update pattern:

  • Reducers return new objects each time
  • Structural sharing reduces memory copying
  • past only holds references

Pros:

  • Simple, straightforward implementation
  • Efficient enough for small state
  • Supports fine-grained controls like filter and groupBy

Limitations:

  • With Redux-undo plus Immer or immutable updates, unmodified subtrees share references. But each change still creates new objects for the changed node and all of its ancestors. For deep structures, even a single-field change can rebuild an entire parent chain.
  • Each history entry is effectively a full state snapshot
  • Tightly coupled to Redux; not usable elsewhere

For a simple counter, this is perfectly fine. But for a complex document object, 100 history entries imply 100 state references plus each one’s memory footprint.

Zundo: Optional Diff Optimization

Zundo is a tiny (<700B) and flexible undo/redo middleware for Zustand.

Core code:

// zundo/src/temporal.ts
_handleSet: (pastState, replace, currentState, deltaState) => {
  set({
    pastStates: get().pastStates.concat(deltaState || pastState),
    futureStates: [],
  });
};
Enter fullscreen mode Exit fullscreen mode

Note deltaState || pastState:

  • Default stores the full state (pastState)
  • Optionally stores a diff (deltaState, computed via user-provided diff)

Although Zundo exposes a diff option, you must pick and configure a diff library and translate results yourself. For many developers, that adds learning cost. More importantly, diffing a large state tree frequently can itself become a performance bottleneck.

It’s a clever design:

  • Zero-cost abstraction: don’t opt in to diffs, pay no cost
  • Flexibility: choose any diff algorithm (microdiff, fast-json-patch, etc.) or roll your own
  • Control: pair with partialize to track only part of the state

Pros:

  • Supports diff-based storage; can optimize memory
  • Elegant API
  • Deep integration with Zustand

Limitations:

  • You must implement the diff logic yourself—not trivial
  • Zustand-only
  • Default behavior still stores full snapshots

The catch: building a reliable diff is non-trivial. You must:

  1. Pick the right diff library
  2. Write transformation code (like the example)
  3. Handle edge cases (null, undefined, cycles, etc.)
  4. Ensure reversibility (correct undo/redo)

And for very large trees, the diff stage itself can be expensive.

Travels: JSON Patch as a First-Class Citizen

This is where Travels comes in. Its core idea: use JSON Patch as the built-in, default storage format.

import { createTravels } from 'travels';

const travels = createTravels({
  title: 'My Doc',
  content: '...',
  // ... potentially a very large object
});

travels.setState((draft) => {
  draft.title = 'New Title';
});

// Internally, Travels stores:
// {
//   patches: [{ op: "replace", path: ["title"], value: "New Title" }],
//   inversePatches: [{ op: "replace", path: ["title"], value: "My Doc" }]
// }

// You can set { patchesOptions: { pathAsArray: true } } to make `path` a JSON path string.
Enter fullscreen mode Exit fullscreen mode

No configuration required—JSON Patch is default.

Why Mutative?

Travels is built on Mutative. That choice matters:

// Mutative core capability
import { create } from 'mutative';

const [nextState, patches, inversePatches] = create(
  { count: 0 },
  (draft) => {
    draft.count++;
  },
  { enablePatches: true }
);

// patches: [{ op: "replace", path: ["count"], value: 1 }]
// inversePatches: [{ op: "replace", path: ["count"], value: 0 }]
Enter fullscreen mode Exit fullscreen mode

Mutative provides:

  1. Draft API – identical ergonomics to Immer
  2. Native JSON Patch generation – no extra diff library
  3. High performance – official benchmarks show up to 10× over Immer
  4. Zero configuration – patch generation is built-in, not optional

This lets Travels leverage Mutative’s strengths directly, without forcing users to implement diffs like Zundo.

Real Memory Differences

A test with a 100KB complex object and 100 tiny edits (2 fields per edit):

Redux-undo:

// Stores full-state references
pastStates: [
  { /* 100KB object */ },
  { /* 100KB object */ },
  // ...
];
// 100 history entries: ~11.8 MB growth
Enter fullscreen mode Exit fullscreen mode

Zundo (no diff):

// Default stores full state
pastStates: [
  { /* 100KB object */ },
  { /* 100KB object */ },
  // ...
];
// 100 history entries: ~11.8 MB growth
Enter fullscreen mode Exit fullscreen mode

Zundo (with diff):

// Must implement manually
diff: (past, current) => {
  const diff = require('microdiff')(past, current);
  // ... translate diff to your shape
};
// 100 histories: ~0.26 MB growth (≈97.8% saved)
// But you must supply diff logic, and for huge trees the diff stage can bottleneck
Enter fullscreen mode Exit fullscreen mode

Travels:

// Automatically stores JSON Patches
patches: [
  [{ op: 'replace', path: ['field1'], value: 'new' }],   // ~50 bytes
  [{ op: 'replace', path: ['field2'], value: 'newer' }], // ~50 bytes
  // ...
];
// 100 histories: ~0.31 MB growth (≈97.4% saved)
// After serialization only ~20.6 KB (≈99.8% smaller than snapshots)
Enter fullscreen mode Exit fullscreen mode

The gap is stark. For large objects with small edits, Travels can deliver ~40× memory savings, and 500×+ on serialized size.

Framework-Agnostic by Design

Another advantage: Travels is framework-agnostic. The core is a plain state manager:

// React
import { useSyncExternalStore } from 'react';

function useTravel(travels) {
  const state = useSyncExternalStore(
    travels.subscribe.bind(travels),
    travels.getState.bind(travels),
    travels.getState.bind(travels) // for SSR
  );
  return [state, travels.setState.bind(travels), travels.getControls()];
}

// Vue
import { ref } from 'vue';

function useTravel(travels) {
  const state = ref(travels.getState());
  travels.subscribe((newState) => {
    state.value = newState;
  });
  return { state, travels };
}

// Plain JS
travels.subscribe((state) => {
  document.querySelector('#app').innerHTML = render(state);
});
Enter fullscreen mode Exit fullscreen mode

Use it anywhere: React, Vue, Svelte, Angular, or vanilla JS.

Side-by-Side Comparison

Dimension Redux-undo Zundo Travels
Storage model Snapshot references Snapshot by default, diff Built-in JSON Patch
Config complexity Low Medium (you write the diff) Low
Memory efficiency Medium (sharing helps) Low→High (depends on diff) High (out of the box)
Persistence fit Poor (large data) Poor (large data) Excellent (compact + standard)
Frameworks Redux only Zustand only Framework-agnostic
Highlights filter, groupBy partialize, custom diff Auto/manual archive, mutable mode
Core LOC (approx.) ~440 ~250 ~640

A Few Neat Designs in Travels

1) Auto/Manual Archive Modes

// Auto: each setState becomes a history entry
const travels = createTravels({ count: 0 });
travels.setState({ count: 1 }); // +1 history
travels.setState({ count: 2 }); // +1 history

// Manual: batch multiple changes into one entry
const travels = createTravels({ count: 0 }, { autoArchive: false });
travels.setState({ count: 1 });
travels.setState({ count: 2 });
travels.setState({ count: 3 });
travels.archive(); // record only 0→3 as one history entry
Enter fullscreen mode Exit fullscreen mode

Manual mode is ideal for complex interactions. E.g., a drag operation might trigger dozens of updates, but you want it to undo as one unit.

2) Mutable Mode for Reactive Frameworks

In Vue/MobX (Proxy-based reactivity), replacing object references breaks reactivity. Travels offers mutable mode:

// Vue example
import { reactive } from 'vue';

const travels = createTravels(reactive({ count: 0 }), { mutable: true });

// Travels mutates in place, keeping the Proxy reference
travels.setState((draft) => {
  draft.count++;
});
// Vue reactivity stays intact
Enter fullscreen mode Exit fullscreen mode

Implementation sketch:

if (this.mutable) {
  apply(this.state as object, patches, { mutable: true });
  this.pendingState = this.state; // keep the reference stable
} else {
  this.state = apply(this.state as object, patches) as S;
}
Enter fullscreen mode Exit fullscreen mode

This allows seamless use in reactive frameworks.

3) Sliding Window via maxHistory

const travels = createTravels({ count: 0 }, { maxHistory: 3 });

// 5 consecutive ops
increment(); // 1
increment(); // 2
increment(); // 3
increment(); // 4
increment(); // 5

// History window: [2, 3, 4, 5]
// You can undo back to 2, but not to 0 or 1 due to the window size
// however, reset() can return to the initial state 0.
Enter fullscreen mode Exit fullscreen mode

Great for memory-constrained environments.

Hands-On Experience

I refactored a collaborative editor with Travels. Based on benchmark projections for a 100KB doc over 100 edits:

  • Memory: ~11.8MB (snapshots) → ~0.31MB (−97.4%)
  • Persistence size: ~11.6MB → ~121KB (−99%)
  • Serialization speed: ~12ms → ~0.07ms (×180 faster)
  • Code volume: ~40% less (no custom diff code)
  • Undo/redo: millisecond-level per op (~0.88ms average), effectively imperceptible

And the API is intuitive:

const travels = createTravels(initialState);

// Update
travels.setState((draft) => {
  draft.title = 'New';
  draft.sections[0].content = 'Updated';
});

// Time travel
travels.back();     // undo
travels.forward();  // redo
travels.go(5);      // jump to position 5
travels.reset();    // back to initial

// History info
travels.getHistory();
travels.getPosition();
travels.canBack();
travels.canForward();
Enter fullscreen mode Exit fullscreen mode

If you’ve used Immer or Mutative, there’s essentially no learning curve.

Benchmarks

To validate the analysis, I wrote comprehensive benchmarks. Setup:

  • Object size: ~100KB (nested objects/arrays)
  • Ops: 100 tiny edits (2 fields per op)
  • Env: Node.js v22.17.1
  • Memory: measured precisely with --expose-gc

Full code: ./benchmarks/

Results

1) Memory Growth

Approach Memory Growth Savings
Redux-undo (snapshot) 11.8 MB
Zundo (snapshot) 11.8 MB
Zundo (diff) 0.26 MB 97.8%
Travels (JSON Patch) 0.31 MB 97.4%

Key take: for 100 tiny edits on a 100KB object, snapshots take ~12MB; JSON Patch takes ~0.3MB—97%+ saved.

2) setState Throughput

Approach 100 Ops Time Relative
Redux-undo 42.4 ms Baseline
Zundo (snapshot) 43.4 ms close
Zundo (diff) 51.4 ms 21% slower
Travels 87.9 ms 107% slower

Trade-off: Travels’ setState is slower because it generates JSON Patches in real time. The cost is amortized per operation and still in the millisecond range. In return you get:

  • 97% memory savings
  • Extremely fast serialization
  • A standardized operation log

3) Undo/Redo

Approach Undo (×50) Redo (×50)
Redux-undo 0.09 ms 0.12 ms ⭐
Zundo (snapshot) 0.06 ms 0.02 ms ⭐
Zundo (diff) 0.05 ms 0.01 ms ⭐
Travels 18.88 ms 19.00 ms

Snapshot undo/redo is O(1) (swap references). Travels applies patches (O(n)). In practice:

  • ~18ms latency is barely noticeable
  • You gain huge wins in persistence and memory

4) Serialization (Persistence-Critical)

Approach JSON Size stringify parse Savings
Redux-undo 11,627 KB 12.58 ms 23.91 ms
Zundo (snapshot) 11,627 KB 12.27 ms 24.45 ms
Zundo (diff) 118.81 KB 0.58 ms 0.42 ms 99.0%
Travels 20.6 KB 0.07 ms 0.14 ms 99.8%

Key take:

  • Travels serializes to ~20.6KB vs 11MB+ for snapshots (99.8% smaller)
  • stringify is ×180 faster (0.07ms vs 12ms)
  • parse is ×170 faster (0.14ms vs 24ms)

Performance Conclusion

Snapshot approaches (Redux-undo / default Zundo):

  • ✅ Fastest setState
  • ✅ Fastest undo/redo
  • ❌ Huge memory
  • ❌ Large serialized payloads
  • ❌ Poor fit for persistence

Diff approach (Zundo + microdiff):

  • ⚠️ Slower setState (diff cost)
  • ✅ Fast undo/redo
  • ✅ Low memory
  • ✅ Smaller persistence payloads
  • ⚠️ You own the diff complexity

Travels (built-in JSON Patch):

  • ⚠️ Slower setState (patch generation)
  • ⚠️ Slower undo/redo (patch application)
  • ✅ Low memory
  • ✅ Extremely small persistence ⭐
  • ✅ Zero-config, out of the box
  • ✅ Standard format, great for storage/analytics

Recommendation:

  • Simple apps, unconcerned with memory → Redux-undo/Zundo default
  • Need memory optimization and willing to write diffs → Zundo + custom diff
  • Need persistence, cross-framework, plug-and-play → Travels

When to Use It

Travels isn’t a silver bullet; it shines when:

✅ Good fit:

  • State objects are large (>10KB)
  • You need cross-framework reuse
  • Memory-sensitive environments (mobile, embedded)
  • You want fine-grained history control (manual archive, custom windows)

❌ Less ideal:

  • Very simple state (a few fields—Redux-undo is simpler)
  • Deeply invested in Zustand and happy to write diffs (Zundo is lighter)
  • You don’t care about memory

Comparison Summary

Redux-undo is a dependable standard in the Redux ecosystem but is Redux-bound.

Zundo is elegantly designed and supports diff optimization, but pushes diff complexity onto users.

Travels treats JSON Patch as a first-class citizen—out-of-the-box memory savings, framework-agnostic, and high-performance where it matters for persistence.

If you’re building:

  • Rich text editors
  • Graphics/design tools
  • Collaborative editing apps
  • Complex form systems

—i.e., anything with frequent undo/redo—Travels is worth a try.

Technical Details

JSON Patch Reversibility

Travels stores both patches and inversePatches:

// Change: count: 0 -> 1
patches: [{ op: 'replace', path: ['count'], value: 1 }];
inversePatches: [{ op: 'replace', path: ['count'], value: 0 }];

// Redo: apply patches
// Undo: apply inversePatches
Enter fullscreen mode Exit fullscreen mode

This guarantees reliable time travel.

Why Mutative Is Fast

Mutative’s performance comes from:

  1. Copy-on-write – only copy what changes
  2. Shallow copy – shallow by default; deep when needed
  3. Incremental patch generation – not a post-hoc diff

This is typically faster than Immer and far more efficient than JSON.parse(JSON.stringify()).

Why JSON Patch Excels at Persistence

Another key advantage: JSON Patch is extremely well suited to persistence.

Suppose you want “save the entire edit history locally and resume it on next launch”:

Redux-undo / Zundo (full snapshots):

// Data to persist
const dataToSave = {
  past: [
    { title: "Doc", content: "...", metadata: {...} },        // 100KB
    { title: "Doc v2", content: "...", metadata: {...} },     // 100KB
    // ... 100 histories
  ],
  present: { /* current state */ },
  future: []
};

// Write to localStorage/IndexedDB
localStorage.setItem('history', JSON.stringify(dataToSave));
// Problem: 10MB+ of serialized JSON
Enter fullscreen mode Exit fullscreen mode

Travels (JSON Patch):

// Data to persist
const dataToSave = {
  state: travels.getState(),        // current state
  patches: travels.getPatches(),    // only patches, just a few KB
  position: travels.getPosition(),  // index
};

localStorage.setItem('travels-state', JSON.stringify(dataToSave.state));
localStorage.setItem('travels-patches', JSON.stringify(dataToSave.patches));
localStorage.setItem('travels-position', String(dataToSave.position));

// Restore
const travels = createTravels(
  JSON.parse(localStorage.getItem('travels-state')),
  {
    initialPatches: JSON.parse(localStorage.getItem('travels-patches')),
    initialPosition: Number(localStorage.getItem('travels-position')),
  }
);
Enter fullscreen mode Exit fullscreen mode

Practical comparison (100 histories, from the benchmark):

Scenario Redux-undo Zundo Travels
localStorage usage ~11.6 MB ~11.6 MB ~100KB state + 20.6KB patches
JSON.stringify time ~12.6 ms ~12.3 ms ~0.07 ms (×180 faster) ⭐
JSON.parse time ~23.9 ms ~24.5 ms ~0.14 ms (×170 faster) ⭐
Storage savings 99.8%
IndexedDB write speed Slow Slow Fast (500×+ less data)

Why such a gap?

  1. Size: Patches are inherently compact
  2. (De)serialization: tiny payloads make JSON faster
  3. Quotas: localStorage caps at 5–10MB; Travels stores far more history

Real-World Patterns

This matters in many cases:

1) Offline-first apps

// Save to local storage on every change
travels.subscribe(() => {
  // Always-on autosave is feasible because data is tiny
  saveToLocalStorage({
    state: travels.getState(),
    patches: travels.getPatches(),
    position: travels.getPosition(),
  });
});

// Next launch resumes with full undo/redo history
Enter fullscreen mode Exit fullscreen mode

2) Collaborative editing operation logs

// Sync patches to server
travels.subscribe((state, patches, position) => {
  // patches are standard JSON Patch (RFC 6902)
  // Send for:
  // - Operational history
  // - Conflict resolution
  // - Playback/replay
  api.syncPatches(patches);
});
Enter fullscreen mode Exit fullscreen mode

3) Versioning & audit

// JSON Patch doubles as an operation log
const auditLog = travels.getPatches().patches.map((patch, index) => ({
  timestamp: Date.now(),
  user: currentUser,
  changes: patch, // standard, easy to review
  position: index,
}));

// Can export to human-friendly formats:
/*
2024-01-01 10:00:00 - User A:
  - Changed /title from "Draft" to "Final"
  - Added /tags/0 = "published"

2024-01-01 10:05:00 - User B:
  - Replaced /content/paragraphs/3
*/
Enter fullscreen mode Exit fullscreen mode

4) Product analytics

// Analyze editing patterns
const stats = analyzePatches(travels.getPatches());
/*
{
  totalOperations: 150,
  avgPatchSize: 45,  // bytes
  mostEditedFields: ["/title", "/content/section[0]"],
  undoRate: 0.15
}
*/
Enter fullscreen mode Exit fullscreen mode

Here, JSON Patch’s standardized, compact format is a huge win. Snapshot-based Redux-undo/Zundo are simple, but become unwieldy once persistence enters the picture.

Resources

Final Thoughts

Before writing this post, I read the source code of all three libraries carefully. Redux-undo and Zundo are both excellent and have their places.

Travels isn’t a silver bullet; it targets a specific problem: when you need long histories, how do you minimize memory usage, reduce performance overhead, and stay as general and feature-complete as possible? Its philosophy is to trade compute for memory, which pays off the most when state is large, changes are small, and histories are long. No need to pick a diff library, write conversion logic, or wrangle edge cases—those are built in.

If you’re implementing or optimizing undo/redo—especially for collaborative editors, visual design tools, game editors, or any app that needs long operation histories and hits memory/perf ceilings—Travels is worth a try. It may not be the only answer, but it’s certainly one to consider.

This article is based on source reviews of redux-undo v1.1.0, zundo v2.3.0, and travels v0.5.0. If anything is inaccurate, corrections are welcome.

Top comments (0)