I recently built an undo/redo mechanism for a client-side app. What started as “just keep a stack” quickly turned into decisions about grouped state, UI expectations, cancellations, and what it even means for an operation to be “one step”. This is a two-part series: in this first post, I’ll lay out the mental model and the history design that makes undo/redo predictable and maintainable, and in the second post, we’ll build on that foundation to handle transactions and rollback.

Why undo/redo is harder than it looks

Undo/redo looks simple when you picture a single value changing. Real apps rarely behave like that.

Common reasons it gets tricky:

  • User intent is high-level, state mutations are low-level. A single “Move selection” action can touch many objects, multiple collections, and derived caches.
  • Edits are not always symmetric. Some actions create things, others delete, and some rearrange. The reverse action is not always “apply the opposite”.
  • The UI must feel consistent. Users expect Undo to revert exactly what just happened, not “something close”. They also expect Redo to work only until they make a new change, after which Redo usually disappears.
  • Work can be interrupted. Dialog cancel, validation failure, async network rejection, or partial completion can happen mid-operation.
  • State is more than data. Selection, hovered items, in-progress gestures, focus, scroll position, and zoom can be part of the “experience”. You must choose what is in history and what is not.

A good undo/redo system is mostly about defining rules and invariants that match user expectations, then implementing them consistently.

The mental model: your app state is the source of truth

The simplest robust approach is to treat your application like this:

  • There is a canonical state that represents “the document” or “the model”.
  • The UI is a rendering of that state.
  • History stores versions of the canonical state, not UI artifacts.

This gives you two major benefits:

  1. Undo/redo is consistent because you always restore the state first, then render.
  2. You avoid storing things that are difficult or impossible to clone reliably, like DOM nodes, event listeners, canvas contexts, and framework internals.

A practical way to think about it:

  • “Document state” goes into history.
  • “Session state” usually does not, unless you intentionally want it to (like zoom/pan in a design tool).

If you cannot decide, ask: if the user undoes an edit, should this part change too? If yes, it belongs in the history state. If no, keep it out.

History approaches in one minute

There are three popular approaches. Each has a place.

Snapshots

You store complete copies of the relevant state per step.

  • Easy to reason about.
  • Very hard to get logically wrong.
  • Can be memory heavy, so you cap steps or store partial snapshots.

Best when:

  • The state is moderate in size.
  • You want correctness quickly.

Commands

Each history entry is an action that knows how to apply and reverse itself.

  • More code per feature.
  • Can be memory efficient.
  • Can be tricky when multiple actions interact.

Best when:

  • The state is large.
  • Actions are naturally reversible and localized.

Diffs (patches)

Store only what changed.

  • Potentially the most efficient.
  • Highest complexity.
  • Requires a stable identity and careful patch application.

Best when:

  • You need long histories on large documents.
  • You can formalize changes as patches.

In practice, many apps start with snapshots and evolve toward commands or diffs only if performance requires it.

A minimal undo/redo history (two stacks)

Most “regular programs” behave like linear history with a temporary branch:

  • Undo walks backward through time.
  • Redo walks forward only as long as you have not introduced a new change.
  • The moment a new change occurs after undo, the redo future is discarded.

Conceptually:

  • Undo stack: previous states you can return to.
  • Redo stack: states you can reapply after undo.
  • New edit: pushes an entry to undo and clears redo.

This is not just a convention. It matches how people think about “time” in editing: once you choose a new path, the alternate future stops being available.

Important design detail:

  • History entries usually represent “state before the action” rather than “state after the action”. Either works if consistent, but “before” is convenient because you capture it right as an operation starts.

Making buttons behave like real software

Button behavior is part of the undo/redo contract.

Users expect:

  • Undo is disabled when nothing can be undone.
  • Redo is disabled when nothing can be redone.
  • Undo and Redo to reflect the “linear timeline” model.

To make this reliable, your history layer should publish a small capability API, conceptually like:

  • canUndo
  • canRedo
  • counts (optional)
  • labels (optional, like “Undo Move”, “Redo Delete”)

A subtle UX choice:

  • Many apps show only the enabled state.
  • Some show the next action name. This requires you to name history entries.

Even if you never show labels, naming is still useful for debugging and testing.

Snapshotting correctly

Snapshotting fails in two common ways.

Shallow copies

If your state contains nested objects or arrays and you copy only the top level, then future mutations leak into past snapshots, and undo becomes unreliable.

This can look like:

  • Undo does not fully revert
  • Undo changes to unexpected things
  • Redo restores a state that never actually existed

Rule:

  • If you use snapshots, they must be deep copies of the state you store.

Capturing the wrong boundaries

Even with perfect cloning, undo can feel wrong if your “step boundaries” do not match user intent.

Examples:

  • You save a step on every mousemove during drag. Undo becomes “nudge back 1 pixel” instead of “undo the drag”.
  • You save a step for each internal sub-change. Undo becomes noisy and frustrating.

Rule:

  • A history step should represent a user-level intention.

Practically, this means you often begin a step at gesture start and finalize it at gesture end. That idea leads directly to transactions, which is the focus of post 2.

Cloning: the required clone functions

A key decision: “What kinds of things do we allow inside the state?”

JSON-friendly state cloning

If your state is plain data, cloning is straightforward. Modern browsers provide structured cloning, which handles many built-in types. A JSON-based fallback can work, but it is lossy.

You should know the tradeoffs:

  • JSON cloning drops functions, undefined values, and special object types.
  • Dates become strings.
  • Maps and Sets do not round-trip naturally.
  • Circular references break JSON cloning.

If your document state is meant to be serializable (often a good idea), then JSON-friendly design makes history simpler and safer.

HTML and DOM cloning

DOM nodes are not “document state”. They are UI artifacts. Cloning the DOM is possible, but it is rarely what you want for undo/redo.

Why DOM cloning is usually the wrong tool:

  • Event listeners are not preserved by structural DOM cloning.
  • DOM includes transient layout-driven details that are not meaningful in history.
  • Framework-managed DOM can’t be reliably snapshotted without framework-specific tools.

When DOM cloning can make sense:

  • Visual snapshot features (like a “thumbnail history”).
  • Capturing a small isolated subtree for special UI effects.

Practical rule:

  • Store model state, not DOM.
  • Re-render the DOM from model state after undo/redo.

Grouped state changes

“Grouped” simply means: a single user action affects multiple pieces of state and must undo as one step.

This is normal. In fact, most meaningful edits are grouped.

Examples:

  • Creating a connection updates both edges and node metadata.
  • Deleting an item updates the collection, plus selection and maybe layout caches.
  • Applying a style updates multiple objects at once.

Snapshots naturally treat grouped changes as one unit if you define step boundaries correctly and capture all relevant variables together.

The hard part is not grouping. The hard part is deciding when a group becomes a committed history step, especially when cancellations exist.

That is where transactions come in, which we will cover next.

That wraps up the foundation: how to think about state, how history should behave, how to choose step boundaries, and why cloning and “what belongs in history” matter. In the second post, we’ll keep it short and practical, focusing on transactions and cancellation rules, and when rollback should simply avoid committing history versus when it should actively restore state.