/** * useUndoRedo - Generic undo/redo hook for Zustand stores * * Features: * - History tracking with configurable max size * - Undo/redo operations * - localStorage persistence (optional) * - Debounced history recording * - Clear history on demand * * Usage: * ```tsx * const { undo, redo, canUndo, canRedo, recordSnapshot } = useUndoRedo({ * getState: () => myStore.getState().data, * setState: (state) => myStore.setState({ data: state }), * storageKey: 'my-store-history', * }); * ``` */ import { useState, useCallback, useEffect, useRef } from 'react'; export interface UndoRedoOptions { /** Function to get current state snapshot */ getState: () => T; /** Function to restore a state snapshot */ setState: (state: T) => void; /** Maximum history size (default: 50) */ maxHistory?: number; /** localStorage key for persistence (optional) */ storageKey?: string; /** Debounce delay in ms for recording (default: 500) */ debounceMs?: number; /** Custom equality check (default: JSON.stringify comparison) */ isEqual?: (a: T, b: T) => boolean; } export interface UndoRedoResult { /** Undo the last change */ undo: () => void; /** Redo the last undone change */ redo: () => void; /** Whether undo is available */ canUndo: boolean; /** Whether redo is available */ canRedo: boolean; /** Manually record a state snapshot */ recordSnapshot: () => void; /** Clear all history */ clearHistory: () => void; /** Current history length */ historyLength: number; /** Current position in history */ historyPosition: number; /** Get history for debugging */ getHistory: () => T[]; } interface HistoryState { past: T[]; future: T[]; } const DEFAULT_MAX_HISTORY = 50; const DEFAULT_DEBOUNCE_MS = 500; function defaultIsEqual(a: T, b: T): boolean { return JSON.stringify(a) === JSON.stringify(b); } export function useUndoRedo(options: UndoRedoOptions): UndoRedoResult { const { getState, setState, maxHistory = DEFAULT_MAX_HISTORY, storageKey, debounceMs = DEFAULT_DEBOUNCE_MS, isEqual = defaultIsEqual, } = options; // Initialize history from localStorage if available const getInitialHistory = (): HistoryState => { if (storageKey) { try { const stored = localStorage.getItem(storageKey); if (stored) { const parsed = JSON.parse(stored); // Validate structure if (Array.isArray(parsed.past) && Array.isArray(parsed.future)) { return parsed; } } } catch (e) { console.warn('Failed to load undo history from localStorage:', e); } } return { past: [], future: [] }; }; const [history, setHistory] = useState>(getInitialHistory); const debounceTimerRef = useRef | null>(null); const lastRecordedStateRef = useRef(null); const isUndoRedoRef = useRef(false); // Persist to localStorage when history changes useEffect(() => { if (storageKey) { try { localStorage.setItem(storageKey, JSON.stringify(history)); } catch (e) { console.warn('Failed to save undo history to localStorage:', e); } } }, [history, storageKey]); // Record a snapshot to history const recordSnapshot = useCallback(() => { if (isUndoRedoRef.current) { // Don't record during undo/redo operations return; } const currentState = getState(); // Skip if state hasn't changed if (lastRecordedStateRef.current !== null && isEqual(lastRecordedStateRef.current, currentState)) { return; } lastRecordedStateRef.current = currentState; setHistory((prev) => { // Create a deep copy for history const snapshot = JSON.parse(JSON.stringify(currentState)) as T; const newPast = [...prev.past, snapshot]; // Trim history if too long if (newPast.length > maxHistory) { newPast.shift(); } return { past: newPast, future: [], // Clear redo stack on new changes }; }); }, [getState, maxHistory, isEqual]); // Debounced recording const recordSnapshotDebounced = useCallback(() => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } debounceTimerRef.current = setTimeout(() => { recordSnapshot(); }, debounceMs); }, [recordSnapshot, debounceMs]); // Undo operation const undo = useCallback(() => { setHistory((prev) => { if (prev.past.length === 0) { return prev; } const newPast = [...prev.past]; const previousState = newPast.pop()!; // Save current state to future before undoing const currentState = JSON.parse(JSON.stringify(getState())) as T; isUndoRedoRef.current = true; setState(previousState); lastRecordedStateRef.current = previousState; // Reset flag after a tick setTimeout(() => { isUndoRedoRef.current = false; }, 0); return { past: newPast, future: [currentState, ...prev.future], }; }); }, [getState, setState]); // Redo operation const redo = useCallback(() => { setHistory((prev) => { if (prev.future.length === 0) { return prev; } const newFuture = [...prev.future]; const nextState = newFuture.shift()!; // Save current state to past before redoing const currentState = JSON.parse(JSON.stringify(getState())) as T; isUndoRedoRef.current = true; setState(nextState); lastRecordedStateRef.current = nextState; // Reset flag after a tick setTimeout(() => { isUndoRedoRef.current = false; }, 0); return { past: [...prev.past, currentState], future: newFuture, }; }); }, [getState, setState]); // Clear history const clearHistory = useCallback(() => { setHistory({ past: [], future: [] }); lastRecordedStateRef.current = null; if (storageKey) { try { localStorage.removeItem(storageKey); } catch (e) { console.warn('Failed to clear undo history from localStorage:', e); } } }, [storageKey]); // Get history for debugging const getHistory = useCallback(() => { return [...history.past]; }, [history.past]); // Cleanup debounce timer on unmount useEffect(() => { return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } }; }, []); return { undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0, recordSnapshot: recordSnapshotDebounced, clearHistory, historyLength: history.past.length, historyPosition: history.past.length, getHistory, }; } export default useUndoRedo;