Skip to content

Undo / redo

Undo is opt-in per root. By default a store carries no undo history.

ts
import { StoreValue } from "@super-store/store"

const doc = new StoreValue({ text: "" }, { undo: true })
doc.set({ text: "hello" })
doc.undo()           // back to { text: "" }
doc.redo()           // forward to { text: "hello" }

Why opt-in

Yjs's UndoManager pins deleted content (so it can be restored) and disables garbage collection for the types it tracks. That keeps tombstoned data around for the lifetime of the manager — a real cost you should only pay where you actually need history. So super-store never enables it for you.

Enabling it

Turn it on at construction, or later via enableUndo():

ts
const a = new StoreValue({ n: 0 }, { undo: true })

const b = new StoreValue({ n: 0 })
b.enableUndo()

Enabling undo binds the root to its doc (like any sync method or .doc access). It applies to the whole root, including nested StoreValue children.

The API

MemberTypeDescription
undo()voidRevert the last tracked change.
redo()voidRe-apply the last undone change.
canUndobooleanWhether there is anything to undo.
canRedobooleanWhether there is anything to redo.
undoManagerY.UndoManager | nullThe raw Yjs manager; null until undo is enabled.
ts
const store = new StoreValue({ count: 0 }, { undo: true })

store.set({ count: 1 })
store.set({ count: 2 })

store.canUndo   // true
store.canRedo   // false

store.undo()    // { count: 1 }
store.redo()    // { count: 2 }

Guard your UI on canUndo / canRedo rather than tracking history yourself:

ts
if (store.canUndo) store.undo()

Only local edits are tracked

The manager tracks only this store's own writes — those tagged with STORE_ORIGIN. Edits merged in through applyUpdate (a peer's change, or persisted bytes loaded back) are never undone:

ts
import { StoreValue, STORE_ORIGIN } from "@super-store/store"

const local = new StoreValue({ text: "" }, { undo: true })

local.set({ text: "mine" })           // tracked — undoable
local.applyUpdate(remoteUpdateBytes)  // a peer's edit — NOT in local's undo stack

local.undo()                          // reverts only "mine", leaves the remote edit intact

Undo propagates to peers

An undo is just another write. It flows through the normal observer, so:

  • the store's listeners fire and getSnapshot() refreshes,
  • in a synced app the resulting update goes out over onUpdate with meta.local === true, exactly like a user edit.

So pressing undo on one client undoes the change for everyone, the same way the original edit reached them.

ts
store.onUpdate((update, { local }) => {
  // local === true for user writes AND for undo()/redo()
  if (local) bus.send({ update })
})

captureTimeout

Successive edits inside a short window are merged into one undo step. Tune the window with captureTimeout (milliseconds):

ts
// one undo step per burst of edits within 300ms
const store = new StoreValue({ text: "" }, { undo: { captureTimeout: 300 } })

// same option via enableUndo:
const other = new StoreValue({ text: "" })
other.enableUndo({ captureTimeout: 300 })

A larger timeout groups more keystrokes into a single undo; a smaller one (down to 0) makes each set its own step.

A small example

ts
import { StoreValue } from "@super-store/store"

type Doc = { title: string; body: string }

const doc = new StoreValue<Doc>(
  { title: "Untitled", body: "" },
  { undo: { captureTimeout: 500 } },
)

doc.set({ title: "Draft", body: "" })
doc.set({ title: "Draft", body: "First line." })

doc.canUndo          // true
doc.undo()           // body back to ""
doc.undo()           // title back to "Untitled"
doc.canUndo          // false
doc.canRedo          // true
doc.redo()           // title -> "Draft" again

Pair it with React by reading canUndo / canRedo from a snapshot-driven render and calling undo() / redo() from your toolbar handlers.

Released under the MIT License.