[Google Docs] Optimization - Undo/Redo
Undo/Redo demo
There are 2 ways to implement undo/redo mechanism:
- single undo stack and move by index (./src/hooks/useUndoRedoByIndex.ts)
- 2 stacks - undo stack & redo stack (./src/hooks/useUndoRedoByTwoStack.ts)
Single undo stack and move by index
Use a single stack to store the user's operation history, and
- when user undo, move index backward (-1)
- when user redo, move index forward (+1)
const undo = () => {
const prevStep = Math.max(LOWER_BOUND, currentIndex - 1);
setCurrentIndex(prevStep);
}
const redo = () => {
const nextStep = Math.min(UPPER_BOUND, currentIndex + 1);
setCurrentIndex(nextStep);
}
2 Stacks - undo stack & redo stack
Use 2 stacks to store the undo and redo histories
- when user undo
pop
current state from undo stackpush
current state to redo stack- get the latest previous state from the last item in the undo stack
- set state with the latest previous version
const undo = () => {
if (undoStack.current.length <= 1) {
return;
}
const currentValue = undoStack.current.pop();
const prevValue = undoStack.current[undoStack.current.length - 1];
redoStack.current.push(currentValue!);
setValue(prevValue);
}
- when user redo
pop
next state from redo stackpush
redo state to undo stack- set state with next version
const redo = () => {
if (redoStack.current.length === 0) {
return;
}
const nextStep = redoStack.current.pop();
undoStack.current.push(value);
setValue(nextStep!);
}
Comparison
Feature | by index | 🏆 by 2 stacks |
---|---|---|
Implementation | Single array + index | Two stacks (undo/redo) |
Memory Usage | ✅ Less (single array) | ⚠️ More (two arrays) |
Code Complexity | ⚠️ Complex (index management) | ✅ Simple (intuitive push/pop) |
Error Risk | ⚠️ High (index out of bounds) | ✅ Low (no index management) |
Extensibility | ⚠️ Difficult (complex index logic) | ✅ Easy (simple to add features) |
Performance | ✅ Better (fewer slice operations) | ⚠️ Worse (maintain two arrays) |
Readability | ⚠️ Poor (need to understand index logic) | ✅ Good (clear semantics) |
Maintainability | ⚠️ Poor (prone to bugs) | ✅ Good (clear logic) |
Used by |
|
|
Optimization
To prevent memory leak, we can setup undo stack size limit, and when new operation come in, which makes the stack exceed the limit, slice the stack by size of (current stack size - limit)
const push = (value: T) => {
let newUndoStack = undoStack.current.slice(0, currentIndex + 1);
newUndoStack.push(value);
if (undoStack.current.length > limit) {
newUndoStack = newUndoStack.slice(undoStack.current.length - limit);
}
undoStack.current = newUndoStack;
setCurrentIndex(newUndoStack.length - 1);
}
References
- Lexical History plugin source code
- UI Algorithms: A Tiny Undo Stack
- Build an Undo-Redo Hook in React with Keyboard Shortcut Support: Step-by-Step Tutorial