Front-end Engineering Lab

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

  1. Debounce updates: Don't save every keystroke
  2. Limit history size: Keep last 50-100 actions
  3. Use structural sharing: For large objects
  4. Clear on save: Reset history after save
  5. Keyboard shortcuts: Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z
  6. Visual indicators: Show undo/redo availability
  7. Group related actions: Batch related changes
  8. Serialize efficiently: Use patches for large state
  9. Test edge cases: Empty history, max history
  10. Document shortcuts: Tell users about undo/redo

Undo/redo enhances UX dramatically—implement it for any interactive editor or design tool!

On this page