import { useCallback, useEffect, useRef, useState } from "react"; export type AutoSaveStatus = "idle" | "saving" | "saved" | "error"; interface UseAutoSaveOptions { /** Debounce delay in ms. Default: 800. */ debounceMs?: number; /** Equality check. Default: JSON.stringify comparison. */ isEqual?: (a: T, b: T) => boolean; } interface UseAutoSaveResult { status: AutoSaveStatus; errorText: string | null; retry: () => void; } const defaultIsEqual = (a: T, b: T): boolean => JSON.stringify(a) === JSON.stringify(b); export function useAutoSave( value: T, save: (value: T) => Promise, options?: UseAutoSaveOptions, ): UseAutoSaveResult { const [status, setStatus] = useState("idle"); const [errorText, setErrorText] = useState(null); // Refs to avoid stale closures — option refs are intentionally NOT in deps. const debounceRef = useRef(options?.debounceMs ?? 800); const isEqualRef = useRef<(a: T, b: T) => boolean>( options?.isEqual ?? defaultIsEqual, ); const savedValueRef = useRef(value); const latestValueRef = useRef(value); const isSavingRef = useRef(false); const pendingSaveRef = useRef(false); const debounceTimerRef = useRef | null>(null); const savedTimerRef = useRef | null>(null); const saveRef = useRef(save); // Keep mutable refs up to date without triggering effects. saveRef.current = save; latestValueRef.current = value; const performSave = useCallback(async (valueToSave: T): Promise => { if (isSavingRef.current) { pendingSaveRef.current = true; return; } isSavingRef.current = true; pendingSaveRef.current = false; setStatus("saving"); setErrorText(null); try { await saveRef.current(valueToSave); savedValueRef.current = valueToSave; setStatus("saved"); if (savedTimerRef.current !== null) { clearTimeout(savedTimerRef.current); } savedTimerRef.current = setTimeout(() => { setStatus("idle"); savedTimerRef.current = null; }, 2000); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setErrorText(msg); setStatus("error"); } finally { isSavingRef.current = false; // If a pending save was queued while we were saving, run it now. if (pendingSaveRef.current as boolean) { pendingSaveRef.current = false; await performSave(latestValueRef.current); } } }, []); // Debounce on value change. Only `value` and the stable `performSave` are // deps — option refs are read directly to avoid re-running the effect when // the caller recreates option callbacks. useEffect(() => { if (isEqualRef.current(value, savedValueRef.current)) { return; } if (debounceTimerRef.current !== null) { clearTimeout(debounceTimerRef.current); } debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = null; void performSave(latestValueRef.current); }, debounceRef.current); }, [value, performSave]); // Cleanup timers on unmount. useEffect(() => { return (): void => { if (debounceTimerRef.current !== null) { clearTimeout(debounceTimerRef.current); } if (savedTimerRef.current !== null) { clearTimeout(savedTimerRef.current); } }; }, []); const retry = useCallback(() => { void performSave(latestValueRef.current); }, [performSave]); return { status, errorText, retry }; }