PatternsState and Logic
Undo/Redo Implementation
Implement history management with undo/redo functionality
Undo/redo is critical for editors, design tools, and complex forms. This pattern implements history tracking with efficient memory management.
Basic Command Pattern
// utils/history.ts
interface Command<T> {
execute: () => T;
undo: () => void;
redo: () => void;
}
class HistoryManager<T> {
private history: Command<T>[] = [];
private currentIndex = -1;
execute(command: Command<T>): T {
// Execute command
const result = command.execute();
// Remove future history if we're in the middle
this.history = this.history.slice(0, this.currentIndex + 1);
// Add to history
this.history.push(command);
this.currentIndex++;
return result;
}
undo(): void {
if (!this.canUndo()) return;
this.history[this.currentIndex].undo();
this.currentIndex--;
}
redo(): void {
if (!this.canRedo()) return;
this.currentIndex++;
this.history[this.currentIndex].redo();
}
canUndo(): boolean {
return this.currentIndex >= 0;
}
canRedo(): boolean {
return this.currentIndex < this.history.length - 1;
}
clear(): void {
this.history = [];
this.currentIndex = -1;
}
}React Hook
// hooks/useHistory.ts
export function useHistory<T>(initialState: T) {
const [state, setState] = useState(initialState);
const [history, setHistory] = useState<T[]>([initialState]);
const [index, setIndex] = useState(0);
const updateState = (newState: T) => {
// Remove future history
const newHistory = history.slice(0, index + 1);
newHistory.push(newState);
setHistory(newHistory);
setIndex(newHistory.length - 1);
setState(newState);
};
const undo = () => {
if (index > 0) {
const newIndex = index - 1;
setIndex(newIndex);
setState(history[newIndex]);
}
};
const redo = () => {
if (index < history.length - 1) {
const newIndex = index + 1;
setIndex(newIndex);
setState(history[newIndex]);
}
};
const canUndo = index > 0;
const canRedo = index < history.length - 1;
return {
state,
updateState,
undo,
redo,
canUndo,
canRedo,
};
}Text Editor Example
export function TextEditor() {
const {
state: text,
updateState: setText,
undo,
redo,
canUndo,
canRedo,
} = useHistory('');
useEffect(() => {
// Keyboard shortcuts
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
redo();
} else {
undo();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [undo, redo]);
return (
<div>
<div>
<button onClick={undo} disabled={!canUndo}>
Undo (Ctrl+Z)
</button>
<button onClick={redo} disabled={!canRedo}>
Redo (Ctrl+Shift+Z)
</button>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={10}
cols={50}
/>
</div>
);
}Optimized with Debouncing
export function useHistoryWithDebounce<T>(
initialState: T,
delay = 500
) {
const {
state,
updateState,
undo,
redo,
canUndo,
canRedo,
} = useHistory(initialState);
const [tempState, setTempState] = useState(state);
const debouncedUpdate = useMemo(
() => debounce(updateState, delay),
[updateState, delay]
);
const handleChange = (newState: T) => {
setTempState(newState);
debouncedUpdate(newState);
};
return {
state: tempState,
updateState: handleChange,
undo,
redo,
canUndo,
canRedo,
};
}Canvas Drawing with History
interface DrawAction {
type: 'line' | 'circle' | 'rect';
points: { x: number; y: number }[];
color: string;
}
export function useDrawingHistory() {
const [actions, setActions] = useState<DrawAction[]>([]);
const [index, setIndex] = useState(-1);
const addAction = (action: DrawAction) => {
const newActions = actions.slice(0, index + 1);
newActions.push(action);
setActions(newActions);
setIndex(newActions.length - 1);
};
const undo = () => {
if (index >= 0) setIndex(index - 1);
};
const redo = () => {
if (index < actions.length - 1) setIndex(index + 1);
};
const currentActions = actions.slice(0, index + 1);
return {
actions: currentActions,
addAction,
undo,
redo,
canUndo: index >= 0,
canRedo: index < actions.length - 1,
};
}Memory-Efficient History (Patches)
import { applyPatch, createPatch } from 'rfc6902';
interface HistoryState<T> {
initial: T;
patches: any[][];
index: number;
}
export function usePatchHistory<T extends object>(initialState: T) {
const [current, setCurrent] = useState(initialState);
const historyRef = useRef<HistoryState<T>>({
initial: JSON.parse(JSON.stringify(initialState)),
patches: [],
index: -1,
});
const updateState = (newState: T) => {
// Create patch from current to new
const patch = createPatch(current, newState);
// Add patch to history
const { patches, index } = historyRef.current;
const newPatches = patches.slice(0, index + 1);
newPatches.push(patch);
historyRef.current = {
...historyRef.current,
patches: newPatches,
index: newPatches.length - 1,
};
setCurrent(newState);
};
const undo = () => {
const { patches, index, initial } = historyRef.current;
if (index < 0) return;
// Reconstruct state by applying patches up to index - 1
let reconstructed = JSON.parse(JSON.stringify(initial));
for (let i = 0; i < index; i++) {
applyPatch(reconstructed, patches[i]);
}
historyRef.current.index = index - 1;
setCurrent(reconstructed);
};
const redo = () => {
const { patches, index } = historyRef.current;
if (index >= patches.length - 1) return;
const newIndex = index + 1;
const reconstructed = JSON.parse(JSON.stringify(current));
applyPatch(reconstructed, patches[newIndex]);
historyRef.current.index = newIndex;
setCurrent(reconstructed);
};
return {
state: current,
updateState,
undo,
redo,
canUndo: historyRef.current.index >= 0,
canRedo: historyRef.current.index < historyRef.current.patches.length - 1,
};
}Best Practices
- Debounce updates: Don't save every keystroke
- Limit history size: Keep last 50-100 actions
- Use structural sharing: For large objects
- Clear on save: Reset history after save
- Keyboard shortcuts: Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z
- Visual indicators: Show undo/redo availability
- Group related actions: Batch related changes
- Serialize efficiently: Use patches for large state
- Test edge cases: Empty history, max history
- Document shortcuts: Tell users about undo/redo
Undo/redo enhances UX dramatically—implement it for any interactive editor or design tool!