typescriptintermediate

useUndoRedo State History Hook

Add undo/redo capability to any state with history tracking, max size limits, and keyboard shortcuts.

typescript
import { useCallback, useRef, useState, useEffect } from 'react';

interface UndoRedoResult<T> {
  state: T;
  set: (value: T | ((prev: T) => T)) => void;
  undo: () => void;
  redo: () => void;
  canUndo: boolean;
  canRedo: boolean;
  history: T[];
  pointer: number;
}

function useUndoRedo<T>(initialState: T, maxHistory = 50): UndoRedoResult<T> {
  const [history, setHistory] = useState<T[]>([initialState]);
  const [pointer, setPointer] = useState(0);

  const set = useCallback((value: T | ((prev: T) => T)) => {
    setHistory((prev) => {
      const current = prev[pointer];
      const newValue = typeof value === 'function'
        ? (value as (prev: T) => T)(current)
        : value;

      // Truncate future and append
      const newHistory = [...prev.slice(0, pointer + 1), newValue];

      // Limit history size
      if (newHistory.length > maxHistory) newHistory.shift();
      return newHistory;
    });
    setPointer((p) => Math.min(p + 1, maxHistory - 1));
  }, [pointer, maxHistory]);

  const undo = useCallback(() => {
    setPointer((p) => Math.max(0, p - 1));
  }, []);

  const redo = useCallback(() => {
    setPointer((p) => Math.min(history.length - 1, p + 1));
  }, [history.length]);

  // Keyboard shortcuts
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
        e.preventDefault();
        if (e.shiftKey) redo();
        else undo();
      }
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [undo, redo]);

  return {
    state: history[pointer],
    set,
    undo,
    redo,
    canUndo: pointer > 0,
    canRedo: pointer < history.length - 1,
    history,
    pointer,
  };
}

// Usage
function DrawingApp() {
  const { state, set, undo, redo, canUndo, canRedo } = useUndoRedo<string[]>([]);

  return (
    <div className="space-y-3">
      <div className="flex gap-2">
        <button onClick={undo} disabled={!canUndo} className="px-3 py-1 rounded bg-gray-700 disabled:opacity-30">Undo</button>
        <button onClick={redo} disabled={!canRedo} className="px-3 py-1 rounded bg-gray-700 disabled:opacity-30">Redo</button>
        <button onClick={() => set((prev) => [...prev, `Item ${prev.length + 1}`])}
          className="px-3 py-1 rounded bg-blue-600">Add</button>
      </div>
      <ul className="text-gray-300">{state.map((item, i) => <li key={i}>{item}</li>)}</ul>
    </div>
  );
}

export { useUndoRedo };

Use Cases

  • Text editor undo/redo
  • Drawing app history
  • Form state recovery

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.