Skip to content

pie6k/updatepatch

Repository files navigation

updatepatch

npm version npm downloads bundle size license CI

Undo/redo for any mutable state. Works with plain objects, classes, arrays, Maps, and Sets — no immutability required.

Quick start

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.

Install

npm install updatepatch

API

updateWithUndo(target, recipe)

function 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.

applyPatches(target, patches)

function applyPatches<T extends object>(target: T, patches: Patch[]): T

Applies patches to target in place. Returns target for chaining.

Patch

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" }

Recipe<T>

type Recipe<T> = (draft: T) => void;

Supported types

  • Plain objects
  • Custom class instances (prototype and identity preserved through undo/redo)
  • accessor keyword (auto-accessors with private backing fields)
  • Arrays — direct index access and methods: push, pop, splice, shift, unshift, sort, reverse
  • Mapget, set, delete, clear
  • Setadd, 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
  });
});

Undo stack example

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);
}

How it works

  1. updateWithUndo wraps target in a Proxy. Nested objects get their own proxies on access.
  2. Every write, delete, or collection mutation records two patches — one for undo, one for redo.
  3. Array methods (push, splice, etc.) are intercepted at the method level so compound operations produce clean, minimal patches.
  4. Map/Set iteration wraps yielded values in proxies so nested mutations during for...of / .forEach() are tracked.
  5. When the recipe returns, undo patches are reversed (so they apply in correct order) and both lists are returned.

License

MIT

About

Undo/redo for any mutable state. Works with plain objects, classes, arrays, Maps, and Sets

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors