Build a state manager that powers undo and redo the way a real editor does — where a burst of edits counts as one Ctrl+Z step, not one per keystroke. The user types a sentence, drags a shape, pastes a block; each of those is many small changes, but one undo should revert the whole burst. Your manager separates two layers: a draft that accumulates uncommitted edits, and a stack of committed checkpoints that undo/redo navigate between. The draft is where typing happens; a commit() is the moment the editor decides "this is one undoable unit."
class UndoRedoManager {
// Optional initial committed state. Defaults to {}.
constructor(initialState?: Record<string, unknown>);
set(key: string, value: unknown): void; // write to the draft; no undo step
get(key: string): unknown; // read the effective (draft) value
commit(): void; // snapshot the draft as ONE checkpoint
undo(): Record<string, unknown>; // step back, return the restored state
redo(): Record<string, unknown>; // step forward, return the restored state
canUndo(): boolean;
canRedo(): boolean;
}
// Several sets, ONE commit → a single undo reverts all of them.
const m = new UndoRedoManager({ x: 0, y: 0, z: 0 });
m.set('x', 1);
m.set('y', 2);
m.set('z', 3); // all three live in the draft, no undo step yet
m.commit(); // the burst becomes ONE checkpoint
m.undo(); // one step reverts the whole batch
m.get('x'); // → 0
m.get('y'); // → 0
m.canUndo(); // → false (back at the start)
// A commit AFTER an undo invalidates the redo stack.
const m = new UndoRedoManager({ title: 'a' });
m.set('title', 'b');
m.commit(); // checkpoints: a → b, pointer at b
m.undo(); // pointer back at a; redo would return 'b'
m.canRedo(); // → true
m.set('title', 'c');
m.commit(); // diverged: the 'b' branch is discarded
m.canRedo(); // → false
m.get('title'); // → 'c'
set calls before a commit collapse into a single undoable step. set never pushes a checkpoint on its own — only commit does.undo, committing new work abandons the forward branch you stepped off of. You cannot redo into history you've diverged from.get always reads the draft (the effective current value). undo/redo operate on committed checkpoints: each rebuilds the draft from the checkpoint it lands on. An uncommitted draft is not itself an undo step — commit it first if you want it preserved.commit does not push a step (so undo doesn't stop on phantom checkpoints).undo at the oldest checkpoint and redo at the newest are no-ops that return the current state rather than throwing.undo/redo must not corrupt stored history.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.