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.You'll build a state manager that lets the user type freely, then treats a whole burst of edits as a single undoable step — exactly the way Ctrl+Z behaves in a real editor.
Open any text editor and type the word "hello". Now press Ctrl+Z once. You don't lose the letter "o" and keep "hell" — the whole word vanishes. The editor decided that the burst of five keystrokes was one undoable unit. It made that decision at some natural boundary: you paused, you clicked elsewhere, you hit space. That boundary is a commit.
So there are two distinct ideas at play. There's the live, in-progress edit — call it the draft — which changes on every keystroke but is not yet undoable. And there's the committed checkpoint — a frozen snapshot the editor saved at a boundary, which undo and redo step between. Your manager makes that split explicit: set writes the draft, commit freezes it as one checkpoint, and undo/redo walk the list of checkpoints.
Picture two layers. The bottom layer is the current committed checkpoint — a frozen object. The top layer is the draft: a working copy that set mutates. Every get reads the draft (which started life as a copy of the checkpoint underneath it), so the user always sees their in-progress edits. A commit snapshots the draft down into a new checkpoint.
The committed checkpoints live in an array — the history — with a single integer index pointing at the one you're currently on. undo decrements the index; redo increments it. The checkpoints themselves never move or change; only the pointer slides.
That's the entire data model: a history array, an index into it, and a draft object layered on top. Three fields. Every method is a small operation over those three.
The instinct is to make set itself the undoable unit — push a snapshot onto history on every write. After all, that's "save the state so I can get back to it," right?
class UndoRedoManager {
constructor(initialState = {}) {
this.history = [{ ...initialState }];
this.index = 0;
}
set(key, value) {
// Snapshot on every write — each set is its own undo step.
const next = { ...this.history[this.index], [key]: value };
this.history.push(next); // BUG 1: never truncates the redo branch
this.index = this.history.length - 1;
}
get(key) {
return this.history[this.index][key];
}
commit() {} // nothing to do — set already snapshots
undo() {
if (this.index > 0) this.index -= 1;
return this.history[this.index];
}
redo() {
if (this.index < this.history.length - 1) this.index += 1;
return this.history[this.index];
}
canUndo() { return this.index > 0; }
canRedo() { return this.index < this.history.length - 1; }
}
This is wrong in two distinct ways, and both matter.
It undoes per keystroke, not per batch. Type "hello" by calling set('text', 'h'), set('text', 'he'), … set('text', 'hello') — that's five checkpoints. One undo gets you back to "hell", not to the empty string. The user expects one Ctrl+Z to erase the whole word. We collapsed nothing; we exploded one logical edit into five undo steps.
const m = new UndoRedoManager({ text: '' });
m.set('text', 'h');
m.set('text', 'he');
m.set('text', 'hel');
m.set('text', 'hell');
m.set('text', 'hello');
m.undo();
m.get('text'); // → 'hell' ← wrong; the user wanted '' back in one step
It never truncates the redo branch. set always pushes, even right after an undo. Step back from "hello" to "hell", then type a new letter — push adds it to the end of the array, leaving the stale "hello" checkpoint stranded in the middle of history between "hell" and your new edit. Now one undo from the new edit lands on "hello" — the very future you'd diverged away from — instead of going back to "hell". Real editors throw that abandoned branch away the moment you make a new edit.
The fix to the first problem is the draft: let many set calls pile up in a working layer and only snapshot on commit. The fix to the second is to truncate history past the pointer before pushing a new checkpoint.
class UndoRedoManager {
constructor(initialState = {}) {
// Deep-clone the initial state so the caller can't mutate our history
// by holding onto the object they passed in.
const base = clone(initialState);
// The checkpoint stack. history[index] is the committed state we're on.
this.history = [base];
// Pointer into history. Always a valid index, 0..history.length-1.
this.index = 0;
// The uncommitted working layer: a fresh copy of the current checkpoint
// that set() mutates. commit() snapshots it; undo()/redo() rebuild it.
this.draft = clone(base);
}
set(key, value) {
// Mutate only the draft. No checkpoint, so no undo step is created yet.
this.draft[key] = value;
}
get(key) {
// The draft IS the effective state — it starts as a copy of the current
// checkpoint and accumulates any uncommitted sets on top.
return this.draft[key];
}
commit() {
// Snapshot the draft as one checkpoint. If nothing changed since the
// current checkpoint, this is a no-op (no new undo step).
if (shallowEqual(this.draft, this.history[this.index])) return;
// A commit after an undo diverges: drop everything past the pointer so
// we can't redo into a branch we've abandoned.
this.history = this.history.slice(0, this.index + 1);
this.history.push(clone(this.draft));
this.index = this.history.length - 1;
}
undo() {
if (this.canUndo()) this.index -= 1;
return this._restore();
}
redo() {
if (this.canRedo()) this.index += 1;
return this._restore();
}
canUndo() {
return this.index > 0;
}
canRedo() {
return this.index < this.history.length - 1;
}
// Rebuild the draft from the checkpoint we're now pointing at, and hand the
// caller an independent copy so their mutations can't reach into history.
_restore() {
this.draft = clone(this.history[this.index]);
return clone(this.history[this.index]);
}
}
// Deep copy so nested values (arrays, objects) are isolated between snapshots.
function clone(obj) {
if (typeof structuredClone === 'function') return structuredClone(obj);
return JSON.parse(JSON.stringify(obj));
}
// Same keys, same top-level values? Used to detect a no-op commit.
function shallowEqual(a, b) {
const ak = Object.keys(a);
const bk = Object.keys(b);
if (ak.length !== bk.length) return false;
return ak.every((k) => Object.is(a[k], b[k]) || deepEqual(a[k], b[k]));
}
// Structural equality for the values stored under a key (handles arrays /
// nested objects so a commit that re-assigns an equal array is still a no-op).
function deepEqual(a, b) {
if (Object.is(a, b)) return true;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false;
}
const ak = Object.keys(a);
const bk = Object.keys(b);
if (ak.length !== bk.length) return false;
return ak.every((k) => deepEqual(a[k], b[k]));
}
module.exports = { UndoRedoManager };
The shift from the naive version is concentrated in three places. Take each in turn.
set writes the draft, not history. This is the batching fix. The draft is a plain object that starts as a copy of the current checkpoint; set just assigns into it. No push, no checkpoint, so canUndo() is unaffected. Ten set calls leave ten changes sitting in one draft object, indistinguishable from one big change. The history is untouched until commit runs.
commit is where a checkpoint is born — and where divergence is handled. Two guards bracket the push. First, shallowEqual(this.draft, this.history[this.index]) short-circuits a no-op commit: if the draft matches the checkpoint we're already on, committing would add a phantom step that an undo would later stop on for no visible change, so we skip it. Second, this.history.slice(0, this.index + 1) truncates everything after the pointer before pushing. If you'd undone and then commit fresh work, the abandoned future is sliced off — that's the redo-invalidation fix. After the push, the pointer moves to the new last index.
undo/redo move the pointer and rebuild the draft. They change index (guarded so the boundaries are safe no-ops) and then call _restore, which does two things: it resets this.draft to a fresh clone of the now-current checkpoint — so a subsequent set/commit batch starts from the restored state, not from stale draft data — and it returns a separate clone to the caller. That returned clone is why mutating the result of undo can't corrupt history: the caller never gets a reference into the stored array.
Why everything is cloned. A snapshot must be a value, not a shared reference. If commit stored this.draft directly, the next set would mutate the very object sitting in history. If _restore returned this.history[this.index] directly, a caller doing restored.tags.push(x) would edit the stored checkpoint in place. clone (using structuredClone where available, falling back to a JSON round-trip) gives each checkpoint and each returned value its own deep copy, so the layers stay isolated.
Walkthrough 1 — three sets, one commit, one undo reverts all of them. Start from { x: 0, y: 0, z: 0 }.
new UndoRedoManager({x:0,y:0,z:0})
history = [ {x:0,y:0,z:0} ] index = 0
draft = {x:0,y:0,z:0}
set('x', 1) draft = {x:1, y:0, z:0} history unchanged
set('y', 2) draft = {x:1, y:2, z:0} history unchanged
set('z', 3) draft = {x:1, y:2, z:3} history unchanged
canUndo() -> false <- no checkpoint pushed yet
commit() draft != history[0], nothing past index 0 to slice.
history = [ {0,0,0}, {1,2,3} ] index = 1
canUndo() -> true
undo() index 1 > 0 -> index = 0
draft = clone(history[0]) = {x:0,y:0,z:0}
returns {x:0,y:0,z:0}
get('x') -> 0, get('y') -> 0, get('z') -> 0
canUndo() -> false
The three set calls produced exactly one checkpoint, so the single undo reverts x, y, and z together. That's the batching contract: the burst collapsed into one undoable unit.
Walkthrough 2 — undo, a divergent commit, and redo being unavailable. Start from { title: 'a' }.
new UndoRedoManager({title:'a'})
history = [ {title:'a'} ] index = 0
set('title','b'); commit()
history = [ {a}, {b} ] index = 1
undo() index = 0; draft = {title:'a'}
canRedo() -> true <- redo would return {title:'b'}
set('title','c') draft = {title:'c'} history unchanged
commit() draft != history[0]. SLICE first:
history.slice(0, 0+1) = [ {a} ] <- {b} dropped
push clone(draft):
history = [ {a}, {c} ] index = 1
canRedo() -> false <- the {b} branch is gone
redo() index 1 is already the last -> no-op
returns {title:'c'}, get('title') -> 'c'
The slice on the divergent commit is the whole story: stepping back and committing new work permanently discards the forward branch. You can never redo into a history you've edited away from — which is exactly how editors behave.
set pushes a checkpoint, "hello" becomes five undo steps and one Ctrl+Z only deletes the "o". Fix: set writes the draft; only commit pushes a checkpoint. The batch boundary is the commit, not the keystroke.canRedo() lies and redo() jumps into an abandoned branch. Fix: history = history.slice(0, index + 1) before pushing, so everything past the pointer is dropped.commit stores this.draft itself (not a copy), the next set mutates the object already sitting in history — every checkpoint silently becomes the latest state. Fix: clone the draft on commit, and clone again on the value handed back from undo/redo. A snapshot must be a value, not a live reference.canUndo is index > 0 (step back only if there's an earlier checkpoint), and canRedo is index < history.length - 1 (step forward only if there's a later one). Writing index >= 0 or index <= length lets the pointer walk off the array and history[index] becomes undefined. Fix: guard both moves with the exact boundary checks, and reuse canUndo/canRedo inside undo/redo so there's one source of truth.commit with no changes since the last commit — or right after construction — must not add a checkpoint; otherwise undo stops on phantom steps that don't change what the user sees. Fix: compare the draft to the current checkpoint and bail early when they're equal.undo at index 0 and redo at the last index must be safe no-ops, not crashes or pointer underflow/overflow. Fix: the canUndo()/canRedo() guards leave the index untouched at the ends, and _restore still returns the (unchanged) current state, so callers always get a usable object back.undo moves the pointer but leaves the old draft in place, the next get returns stale uncommitted data and the next commit snapshots the wrong base. Fix: _restore rebuilds this.draft from the checkpoint the pointer now lands on.N: after pushing, if history.length > N, drop the oldest checkpoint (history.shift()) and decrement index. The classic "you can only undo 100 steps" behaviour.O(state size) memory per step. For large state, store only the changed keys (a patch like { x: { from: 0, to: 1 } }) and reconstruct by replaying patches from the nearest full snapshot. Trades CPU on undo for far less memory — the approach libraries like Immer's patches use.commit, auto-commit when edits pause for, say, 500ms (a debounce). Rapid typing stays one checkpoint; a pause starts a new one. This is how editors decide batch boundaries without an explicit "save" press — combine it with an explicit commit for hard boundaries like a paste or a click elsewhere.jumpTo(label) that sets index directly. Useful for a visible history panel where the user picks a point to restore rather than pressing undo repeatedly.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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.You'll build a state manager that lets the user type freely, then treats a whole burst of edits as a single undoable step — exactly the way Ctrl+Z behaves in a real editor.
Open any text editor and type the word "hello". Now press Ctrl+Z once. You don't lose the letter "o" and keep "hell" — the whole word vanishes. The editor decided that the burst of five keystrokes was one undoable unit. It made that decision at some natural boundary: you paused, you clicked elsewhere, you hit space. That boundary is a commit.
So there are two distinct ideas at play. There's the live, in-progress edit — call it the draft — which changes on every keystroke but is not yet undoable. And there's the committed checkpoint — a frozen snapshot the editor saved at a boundary, which undo and redo step between. Your manager makes that split explicit: set writes the draft, commit freezes it as one checkpoint, and undo/redo walk the list of checkpoints.
Picture two layers. The bottom layer is the current committed checkpoint — a frozen object. The top layer is the draft: a working copy that set mutates. Every get reads the draft (which started life as a copy of the checkpoint underneath it), so the user always sees their in-progress edits. A commit snapshots the draft down into a new checkpoint.
The committed checkpoints live in an array — the history — with a single integer index pointing at the one you're currently on. undo decrements the index; redo increments it. The checkpoints themselves never move or change; only the pointer slides.
That's the entire data model: a history array, an index into it, and a draft object layered on top. Three fields. Every method is a small operation over those three.
The instinct is to make set itself the undoable unit — push a snapshot onto history on every write. After all, that's "save the state so I can get back to it," right?
class UndoRedoManager {
constructor(initialState = {}) {
this.history = [{ ...initialState }];
this.index = 0;
}
set(key, value) {
// Snapshot on every write — each set is its own undo step.
const next = { ...this.history[this.index], [key]: value };
this.history.push(next); // BUG 1: never truncates the redo branch
this.index = this.history.length - 1;
}
get(key) {
return this.history[this.index][key];
}
commit() {} // nothing to do — set already snapshots
undo() {
if (this.index > 0) this.index -= 1;
return this.history[this.index];
}
redo() {
if (this.index < this.history.length - 1) this.index += 1;
return this.history[this.index];
}
canUndo() { return this.index > 0; }
canRedo() { return this.index < this.history.length - 1; }
}
This is wrong in two distinct ways, and both matter.
It undoes per keystroke, not per batch. Type "hello" by calling set('text', 'h'), set('text', 'he'), … set('text', 'hello') — that's five checkpoints. One undo gets you back to "hell", not to the empty string. The user expects one Ctrl+Z to erase the whole word. We collapsed nothing; we exploded one logical edit into five undo steps.
const m = new UndoRedoManager({ text: '' });
m.set('text', 'h');
m.set('text', 'he');
m.set('text', 'hel');
m.set('text', 'hell');
m.set('text', 'hello');
m.undo();
m.get('text'); // → 'hell' ← wrong; the user wanted '' back in one step
It never truncates the redo branch. set always pushes, even right after an undo. Step back from "hello" to "hell", then type a new letter — push adds it to the end of the array, leaving the stale "hello" checkpoint stranded in the middle of history between "hell" and your new edit. Now one undo from the new edit lands on "hello" — the very future you'd diverged away from — instead of going back to "hell". Real editors throw that abandoned branch away the moment you make a new edit.
The fix to the first problem is the draft: let many set calls pile up in a working layer and only snapshot on commit. The fix to the second is to truncate history past the pointer before pushing a new checkpoint.
class UndoRedoManager {
constructor(initialState = {}) {
// Deep-clone the initial state so the caller can't mutate our history
// by holding onto the object they passed in.
const base = clone(initialState);
// The checkpoint stack. history[index] is the committed state we're on.
this.history = [base];
// Pointer into history. Always a valid index, 0..history.length-1.
this.index = 0;
// The uncommitted working layer: a fresh copy of the current checkpoint
// that set() mutates. commit() snapshots it; undo()/redo() rebuild it.
this.draft = clone(base);
}
set(key, value) {
// Mutate only the draft. No checkpoint, so no undo step is created yet.
this.draft[key] = value;
}
get(key) {
// The draft IS the effective state — it starts as a copy of the current
// checkpoint and accumulates any uncommitted sets on top.
return this.draft[key];
}
commit() {
// Snapshot the draft as one checkpoint. If nothing changed since the
// current checkpoint, this is a no-op (no new undo step).
if (shallowEqual(this.draft, this.history[this.index])) return;
// A commit after an undo diverges: drop everything past the pointer so
// we can't redo into a branch we've abandoned.
this.history = this.history.slice(0, this.index + 1);
this.history.push(clone(this.draft));
this.index = this.history.length - 1;
}
undo() {
if (this.canUndo()) this.index -= 1;
return this._restore();
}
redo() {
if (this.canRedo()) this.index += 1;
return this._restore();
}
canUndo() {
return this.index > 0;
}
canRedo() {
return this.index < this.history.length - 1;
}
// Rebuild the draft from the checkpoint we're now pointing at, and hand the
// caller an independent copy so their mutations can't reach into history.
_restore() {
this.draft = clone(this.history[this.index]);
return clone(this.history[this.index]);
}
}
// Deep copy so nested values (arrays, objects) are isolated between snapshots.
function clone(obj) {
if (typeof structuredClone === 'function') return structuredClone(obj);
return JSON.parse(JSON.stringify(obj));
}
// Same keys, same top-level values? Used to detect a no-op commit.
function shallowEqual(a, b) {
const ak = Object.keys(a);
const bk = Object.keys(b);
if (ak.length !== bk.length) return false;
return ak.every((k) => Object.is(a[k], b[k]) || deepEqual(a[k], b[k]));
}
// Structural equality for the values stored under a key (handles arrays /
// nested objects so a commit that re-assigns an equal array is still a no-op).
function deepEqual(a, b) {
if (Object.is(a, b)) return true;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false;
}
const ak = Object.keys(a);
const bk = Object.keys(b);
if (ak.length !== bk.length) return false;
return ak.every((k) => deepEqual(a[k], b[k]));
}
module.exports = { UndoRedoManager };
The shift from the naive version is concentrated in three places. Take each in turn.
set writes the draft, not history. This is the batching fix. The draft is a plain object that starts as a copy of the current checkpoint; set just assigns into it. No push, no checkpoint, so canUndo() is unaffected. Ten set calls leave ten changes sitting in one draft object, indistinguishable from one big change. The history is untouched until commit runs.
commit is where a checkpoint is born — and where divergence is handled. Two guards bracket the push. First, shallowEqual(this.draft, this.history[this.index]) short-circuits a no-op commit: if the draft matches the checkpoint we're already on, committing would add a phantom step that an undo would later stop on for no visible change, so we skip it. Second, this.history.slice(0, this.index + 1) truncates everything after the pointer before pushing. If you'd undone and then commit fresh work, the abandoned future is sliced off — that's the redo-invalidation fix. After the push, the pointer moves to the new last index.
undo/redo move the pointer and rebuild the draft. They change index (guarded so the boundaries are safe no-ops) and then call _restore, which does two things: it resets this.draft to a fresh clone of the now-current checkpoint — so a subsequent set/commit batch starts from the restored state, not from stale draft data — and it returns a separate clone to the caller. That returned clone is why mutating the result of undo can't corrupt history: the caller never gets a reference into the stored array.
Why everything is cloned. A snapshot must be a value, not a shared reference. If commit stored this.draft directly, the next set would mutate the very object sitting in history. If _restore returned this.history[this.index] directly, a caller doing restored.tags.push(x) would edit the stored checkpoint in place. clone (using structuredClone where available, falling back to a JSON round-trip) gives each checkpoint and each returned value its own deep copy, so the layers stay isolated.
Walkthrough 1 — three sets, one commit, one undo reverts all of them. Start from { x: 0, y: 0, z: 0 }.
new UndoRedoManager({x:0,y:0,z:0})
history = [ {x:0,y:0,z:0} ] index = 0
draft = {x:0,y:0,z:0}
set('x', 1) draft = {x:1, y:0, z:0} history unchanged
set('y', 2) draft = {x:1, y:2, z:0} history unchanged
set('z', 3) draft = {x:1, y:2, z:3} history unchanged
canUndo() -> false <- no checkpoint pushed yet
commit() draft != history[0], nothing past index 0 to slice.
history = [ {0,0,0}, {1,2,3} ] index = 1
canUndo() -> true
undo() index 1 > 0 -> index = 0
draft = clone(history[0]) = {x:0,y:0,z:0}
returns {x:0,y:0,z:0}
get('x') -> 0, get('y') -> 0, get('z') -> 0
canUndo() -> false
The three set calls produced exactly one checkpoint, so the single undo reverts x, y, and z together. That's the batching contract: the burst collapsed into one undoable unit.
Walkthrough 2 — undo, a divergent commit, and redo being unavailable. Start from { title: 'a' }.
new UndoRedoManager({title:'a'})
history = [ {title:'a'} ] index = 0
set('title','b'); commit()
history = [ {a}, {b} ] index = 1
undo() index = 0; draft = {title:'a'}
canRedo() -> true <- redo would return {title:'b'}
set('title','c') draft = {title:'c'} history unchanged
commit() draft != history[0]. SLICE first:
history.slice(0, 0+1) = [ {a} ] <- {b} dropped
push clone(draft):
history = [ {a}, {c} ] index = 1
canRedo() -> false <- the {b} branch is gone
redo() index 1 is already the last -> no-op
returns {title:'c'}, get('title') -> 'c'
The slice on the divergent commit is the whole story: stepping back and committing new work permanently discards the forward branch. You can never redo into a history you've edited away from — which is exactly how editors behave.
set pushes a checkpoint, "hello" becomes five undo steps and one Ctrl+Z only deletes the "o". Fix: set writes the draft; only commit pushes a checkpoint. The batch boundary is the commit, not the keystroke.canRedo() lies and redo() jumps into an abandoned branch. Fix: history = history.slice(0, index + 1) before pushing, so everything past the pointer is dropped.commit stores this.draft itself (not a copy), the next set mutates the object already sitting in history — every checkpoint silently becomes the latest state. Fix: clone the draft on commit, and clone again on the value handed back from undo/redo. A snapshot must be a value, not a live reference.canUndo is index > 0 (step back only if there's an earlier checkpoint), and canRedo is index < history.length - 1 (step forward only if there's a later one). Writing index >= 0 or index <= length lets the pointer walk off the array and history[index] becomes undefined. Fix: guard both moves with the exact boundary checks, and reuse canUndo/canRedo inside undo/redo so there's one source of truth.commit with no changes since the last commit — or right after construction — must not add a checkpoint; otherwise undo stops on phantom steps that don't change what the user sees. Fix: compare the draft to the current checkpoint and bail early when they're equal.undo at index 0 and redo at the last index must be safe no-ops, not crashes or pointer underflow/overflow. Fix: the canUndo()/canRedo() guards leave the index untouched at the ends, and _restore still returns the (unchanged) current state, so callers always get a usable object back.undo moves the pointer but leaves the old draft in place, the next get returns stale uncommitted data and the next commit snapshots the wrong base. Fix: _restore rebuilds this.draft from the checkpoint the pointer now lands on.N: after pushing, if history.length > N, drop the oldest checkpoint (history.shift()) and decrement index. The classic "you can only undo 100 steps" behaviour.O(state size) memory per step. For large state, store only the changed keys (a patch like { x: { from: 0, to: 1 } }) and reconstruct by replaying patches from the nearest full snapshot. Trades CPU on undo for far less memory — the approach libraries like Immer's patches use.commit, auto-commit when edits pause for, say, 500ms (a debounce). Rapid typing stays one checkpoint; a pause starts a new one. This is how editors decide batch boundaries without an explicit "save" press — combine it with an explicit commit for hard boundaries like a paste or a click elsewhere.jumpTo(label) that sets index directly. Useful for a visible history panel where the user picks a point to restore rather than pressing undo repeatedly.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.