Undo/redo for any mutable state. Works with plain objects, classes, arrays, Maps, and Sets — no immutability required.
Call updateWithUndo with any object and a callback. Inside the callback, draft behaves exactly like the original object — you can read properties, call methods, iterate, do conditional logic, anything you'd normally do. The only difference is that every mutation is silently recorded so it can later produce undo/redo patches.
import { updateWithUndo, applyPatches } from "updatepatch";
const state = { name: "Alice", tags: ["admin"], score: 5 };
const [undo, redo] = updateWithUndo(state, (draft) => {
if (draft.score > 3) {
draft.name = "Bob";
draft.tags.push("editor");
}
});After the callback runs, state is mutated:
state.name; // "Bob"
state.tags; // ["admin", "editor"]The returned undo and redo are arrays of patches. Apply undo to reverse everything that just happened, and redo to replay it:
applyPatches(state, undo);
state.name; // "Alice"
state.tags; // ["admin"]
applyPatches(state, redo);
state.name; // "Bob"
state.tags; // ["admin", "editor"]This works with any object type — classes, nested objects, Maps, Sets, arrays. Each patch holds a direct reference to the mutated object and a single property key, so applying them requires no path resolution.
No copies of your objects are ever made. All original references are preserved — if you undo the removal of an item from an array, the restored item is the exact same object (===), not a clone.
npm install updatepatchfunction updateWithUndo<T extends object>(
target: T,
recipe: (draft: T) => void,
): [undo: Patch[], redo: Patch[]]target is the object to mutate — plain object, class instance, array, Map, or Set.
recipe receives a draft proxy over target. Reads return current values (nested proxies for objects). Writes mutate the underlying object and record patches.
Returns [undo, redo]. Apply undo to reverse, redo to replay.
updateWithUndo(state, (draft) => {
// reads
console.log(draft.score); // 100
// writes — mutates state.score AND records a patch
draft.score = 200;
// reads reflect the mutation
console.log(draft.score); // 200
// works recursively through nested objects, arrays, Maps, Sets
draft.player.inventory.push(sword);
draft.metadata.set("updatedAt", Date.now());
draft.flags.add("dirty");
});No-op writes (same value) are skipped — no patches generated.
function applyPatches<T extends object>(target: T, patches: Patch[]): TApplies patches to target in place. Returns target for chaining.
interface Patch {
op: "replace" | "remove" | "add";
target: object;
path: string | number;
value?: any;
}Each patch holds a direct reference to the object being mutated (target) and a single property key (path). No path arrays, no resolution — patches apply directly.
// draft.player.hp = 50
{ op: "replace", target: state.player, path: "hp", value: 50 }
// draft.items.push("sword")
{ op: "add", target: state.items, path: 2, value: "sword" }
// delete draft.tempData
{ op: "remove", target: state, path: "tempData" }type Recipe<T> = (draft: T) => void;- Plain objects
- Custom class instances (prototype and identity preserved through undo/redo)
accessorkeyword (auto-accessors with private backing fields)- Arrays — direct index access and methods:
push,pop,splice,shift,unshift,sort,reverse Map—get,set,delete,clearSet—add,delete,clear- Property addition and deletion
- Nested objects at any depth
Iteration over Maps and Sets (for...of, .forEach(), .values(), .entries()) returns proxied values, so nested mutations during iteration are tracked:
updateWithUndo(state, (draft) => {
for (const [key, user] of draft.users) {
user.lastSeen = now; // tracked
}
draft.scores.forEach((item, i) => {
if (i % 2 === 0) item.value *= 2; // tracked
});
});import { updateWithUndo, applyPatches, Patch } from "updatepatch";
type Entry = { undo: Patch[]; redo: Patch[] };
const history: Entry[] = [];
let cursor = -1;
function update<T extends object>(target: T, recipe: (draft: T) => void) {
const [undo, redo] = updateWithUndo(target, recipe);
history.length = cursor + 1;
history.push({ undo, redo });
cursor++;
}
function undo<T extends object>(target: T) {
if (cursor < 0) return;
applyPatches(target, history[cursor].undo);
cursor--;
}
function redo<T extends object>(target: T) {
if (cursor >= history.length - 1) return;
cursor++;
applyPatches(target, history[cursor].redo);
}updateWithUndowrapstargetin aProxy. Nested objects get their own proxies on access.- Every write, delete, or collection mutation records two patches — one for undo, one for redo.
- Array methods (
push,splice, etc.) are intercepted at the method level so compound operations produce clean, minimal patches. - Map/Set iteration wraps yielded values in proxies so nested mutations during
for...of/.forEach()are tracked. - When the recipe returns, undo patches are reversed (so they apply in correct order) and both lists are returned.