
Undo/Redo in a Client-side App: Part 2 - Transactions, Cancellation, Rollback, Performance, and How to Test History
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.
Leave a Reply
Your e-mail address will not be published. Required fields are marked *