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);
}
Its strategy: store references to each state object.
{
past: [state1, state2, state3], // array of object references
present: state4,
future: [state5, state6]
}
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
andgroupBy
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: [],
});
};
Note deltaState || pastState
:
-
Default stores the full state (
pastState
) -
Optionally stores a diff (
deltaState
, computed via user-provideddiff
)
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:
- Pick the right diff library
- Write transformation code (like the example)
- Handle edge cases (null, undefined, cycles, etc.)
- 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.
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 }]
Mutative provides:
- Draft API – identical ergonomics to Immer
- Native JSON Patch generation – no extra diff library
- High performance – official benchmarks show up to 10× over Immer
- 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
Zundo (no diff):
// Default stores full state
pastStates: [
{ /* 100KB object */ },
{ /* 100KB object */ },
// ...
];
// 100 history entries: ~11.8 MB growth
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
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)
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);
});
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
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
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;
}
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.
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();
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
This guarantees reliable time travel.
Why Mutative Is Fast
Mutative’s performance comes from:
- Copy-on-write – only copy what changes
- Shallow copy – shallow by default; deep when needed
- 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
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')),
}
);
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?
- Size: Patches are inherently compact
- (De)serialization: tiny payloads make JSON faster
- 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
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);
});
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
*/
4) Product analytics
// Analyze editing patterns
const stats = analyzePatches(travels.getPatches());
/*
{
totalOperations: 150,
avgPatchSize: 45, // bytes
mostEditedFields: ["/title", "/content/section[0]"],
undoRate: 0.15
}
*/
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
- Travels: https://github.com/mutativejs/travels
- use-travel: https://github.com/mutativejs/use-travel (React hook)
- zustand-travel: https://github.com/mutativejs/zustand-travel (Zustand middleware)
- Mutative: https://github.com/unadlib/mutative
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)