Engineering Journal
Schema Editor
Schema Editor

Snapshot Undo Systems Fail When You Capture Before-State at the Wrong Moment

2026-06-04

TLDR

Snapshot-based undo systems rely on two snapshots per edit action: the exact state before an edit, and the state after. For continuous input controls (text boxes, numeric sliders, drag handles), capturing before-state when edits commit (blur or change) captures a state that already contains user modifications, rendering undo a no-op (before === after). Capturing the before-state upon interaction start (focus, pointerdown) guarantees accurate state recovery.
Interaction PhaseEvent HookCaptured StateUndo Result
Commit Time (Flawed)blur / changeValue already modifiedNo-op (before state equals after state)
Interaction Start (Correct)focus / pointerdownOriginal unmodified state100% Deterministic Undo Recovery

Problem statement: the before-state timing defect

Snapshot-based undo stores state pairs: { before, after }. Undo applies before; Redo applies after.

This model works smoothly for discrete canvas actions (deleting an element, placing a symbol).

However, it breaks on continuous input controls: property inputs, sliders, and drag handles. If an author types a new coordinate into a number field, when should the "before" snapshot be recorded?


Technical failure mode: identical state snapshots

Developers often trigger history pushes inside input change or blur event listeners:

// DEFECTIVE IMPLEMENTATION: Capturing before-state at commit time
$('#width-input').on('change', function() {
  const before = captureState(); // BUG: Reads DOM input value AFTER modification!
  applyWidth(this.value);
  const after = captureState();

pushHistory('Width Edit', before, after); // before === after -> Undo does nothing! });

Because change fires after the user modifies the input value, calling captureState() inside the change handler reads the new DOM value.

The before and after snapshots become identical, rendering Ctrl+Z completely ineffective.


The fix & architecture: focus-time & pointerdown state capture

Capture the beforeState at the moment interaction begins (focus for text inputs, pointerdown for drag handles), and record the afterState when the edit commits:

let interactionBeforeState = null;

// 1. Capture before-state BEFORE modification begins $(document).on('focus', '#width-input, #height-input, #x-input', function() { interactionBeforeState = captureState(); });

// 2. Push history snapshot using the pre-captured before-state $(document).on('change', '#width-input, #height-input, #x-input', function() { if (!interactionBeforeState) return;

applyPropertyChange(this.id, this.value); const afterState = captureState();

pushHistory('Property Edit', interactionBeforeState, afterState); interactionBeforeState = null; // Clear focus capture });

Drag handle state capture

Apply the same pattern to canvas drag handles:
handle.addEventListener('pointerdown', () => {
  dragBeforeState = captureState(); // Capture before drag starts
});

handle.addEventListener('pointerup', () => { if (!dragBeforeState) return; pushHistory('Move Operation', dragBeforeState, captureState()); dragBeforeState = null; });

Rule of thumb: Capture the before-state at interaction initiation (focus, pointerdown), not at commit time (blur, change), to prevent identical before/after undo snapshots.
Read this post in the full Engineering Journal →