In the first post, we set the groundwork: treat the model as the source of truth, keep the UI derivable from state, use a linear undo/redo history that matches user expectations, and choose cloning and step boundaries that align with user intent. Now we’ll build on that in the same order as the demos: History basics (01) shows the core timeline model users expect, with undo and redo stacks, redo being cleared on new edits, and buttons enabling and disabling correctly; Transaction (throw) (02) adds a commit-only wrapper where cancellation or failure is signaled by throwing, so incomplete operations never become undo steps; Transaction (return false) (03) covers the same idea but treats cancellation as normal control flow by returning false, which some teams prefer for readability.

Transactions: don’t pollute history on cancel

A transaction is a simple idea:

  • Capture a “before” snapshot at the start of a user-level operation.
  • Perform work (possibly multiple state mutations).
  • Only if the operation finishes successfully, commit that snapshot into the undo history.
  • If the operation is cancelled or fails, do not commit.

This prevents a common bad experience:

  • user cancels an operation
  • undo now appears enabled
  • pressing undo changes something even though “nothing happened.”

Transactions keep history aligned with user intent: cancelled actions should not create history.

Two transaction styles

There are two common ways to represent “cancelled”.

Style A: cancellation as an exception

The operation throws (or rejects) to indicate it did not complete.

Why this can be good:

  • It fits naturally when failures already use exceptions.
  • It centralizes the “no commit on failure” rule in one place.

Tradeoff:

  • Some teams prefer exceptions only for unexpected errors, not user cancellation.

Style B: cancellation as a return value

The operation returns a value that indicates success or cancellation (for example, false).

Why this can be good:

  • It treats cancellation as normal control flow.
  • It is explicit at the call site.

Tradeoff:

  • You must be disciplined about checking the return value.
  • It can be easier to forget to handle it consistently.

Pick one style and apply it everywhere. Mixed styles lead to subtle bugs where some cancelled operations accidentally commit.

The rollback question

There are two meanings of “rollback”, and they matter.

Rollback type 1: rollback only discards pending history

This means:

  • If the operation cancels, nothing is added to history.
  • But it does not automatically restore the state.

This is fine only if:

  • You do not mutate state before you know you will commit, or
  • You manually revert changes on cancel, or
  • Your operation structures change in a way that cancellation happens before mutation.

Rollback type 2: rollback restores the state

This means:

  • If the operation cancels at any point, you restore the “before” snapshot and re-render.
  • No history step is committed.

This is safer when:

  • Operations mutate the state in multiple stages
  • Cancellation can happen after partial changes
  • Failures can occur late

Tradeoff:

  • Restoring the state can be expensive if snapshots are large
  • You must ensure restore is consistent and triggers a correct re-render

A common rule:

  • Use “discard pending only” for simple operations.
  • Use “restore on rollback” for multi-step or async operations where partial mutation is likely.

Performance and memory

Snapshot history can be very performant if you apply a few constraints.

Cap history size

Most apps do not need unlimited undo. 20 to 200 steps is often enough, depending on state size.

Reduce snapshot size

You rarely need to snapshot everything.

Examples:

  • Store document state but not ephemeral UI state.
  • Store only the modified region for very large documents (a partial snapshot).
  • Store minimal serializable state instead of full runtime structures.

Avoid capturing too frequently

Saving a snapshot per keystroke can be correct, but sometimes feels noisy. Many editors merge typing into larger steps using time-based grouping.

Common strategy:

  • Group changes during continuous input and commit when the user pauses, or when focus changes, or on explicit “apply”.

Be careful with derived data

If you have caches or derived values, decide whether to:

  • Include them in snapshots (simple but can be larger), or
  • Recompute them on restore (often better)

Common pitfalls

Forgetting to clear redo on new edits

This breaks the linear timeline expectation and makes redo unpredictable.

Step boundaries that do not match user intent

Undo feels “wrong” even if logically correct.

Mutating state outside the history system

If some features modify state without going through the history boundary, undo/redo becomes inconsistent and bugs become hard to reproduce.

A strong practice:

  • funnel all meaningful mutations through a small set of “operations” functions that manage history.

Storing non-cloneable values in state

DOM nodes, functions, class instances, and framework internals make snapshots fragile.

Overlapping transactions

If you allow begin to be called while a transaction is active, you can:

  • Lose a pending snapshot
  • Commit the wrong snapshot
  • Restore stale state

If you need nesting, define explicit behavior (advanced). Otherwise, enforce “only one transaction active”.

Testing your undo/redo

Undo/redo is extremely testable because it is deterministic.

Useful invariants:

  • After an operation is committed, undo should restore the exact pre-operation state.
  • After undoing and redoing, the state should match the post-operation state.
  • After undo, performing a new operation clears redo.
  • Cancelled operations do not change the history length.
  • If rollback restores state, cancellation returns state to exactly the pre-transaction snapshot.

Also test step boundaries:

  • Drag should undo as one step.
  • Multi-object operations should undo as one step.
  • Typing may be grouped, depending on your design.

Add tests around edge cases:

  • Empty stacks
  • Repeated undo past the beginning
  • Repeated redo past the end
  • New document load clearing history

Wrap-up and next steps

A snapshot-based history with transactions is a strong baseline that supports:

  • Grouped state changes
  • Realistic undo/redo button behavior
  • Cancellation without a polluted history
  • Safe rollback when needed

When you outgrow snapshots, the next evolution is usually:

  • Partial snapshots (only the part of the state that changes)
  • Command pattern for well-defined reversible actions
  • Diffs for large documents and long histories
  • Nested transactions if complex workflows require it

The real achievement is not cleverness. It is a small set of rules enforced consistently across every state mutation. When you get that right, undo/redo becomes boring, predictable, and trusted, which is exactly what users want.