app/hooks/useAutoSave.ts (100 lines of code) (raw):

import { useEffect, useRef, useCallback, useState } from "react"; interface AutoSaveOptions { delay?: number; // Delay in milliseconds before auto-save triggers onSave: () => Promise<void> | void; // Function to call when auto-saving onSuccess?: () => void; // Optional success callback onError?: (error: Error) => void; // Optional error callback enabled?: boolean; // Whether auto-save is enabled } interface AutoSaveState { isAutoSaving: boolean; lastSaved: Date | null; hasUnsavedChanges: boolean; } interface AutoSaveReturn extends AutoSaveState { triggerSave: () => void; markAsChanged: () => void; markAsSaved: () => void; } export const useAutoSave = ( dependencies: any[], options: AutoSaveOptions ): AutoSaveReturn => { const { delay = 500, // Much faster default - 500ms onSave, onSuccess, onError, enabled = true, } = options; const timeoutRef = useRef<NodeJS.Timeout | null>(null); const isAutoSavingRef = useRef(false); const lastSavedRef = useRef<Date | null>(null); const hasUnsavedChangesRef = useRef(false); const initialRenderRef = useRef(true); // Force re-render when state changes const [, forceUpdate] = useState({}); const rerender = useCallback(() => forceUpdate({}), []); const markAsChanged = useCallback(() => { hasUnsavedChangesRef.current = true; rerender(); }, [rerender]); const markAsSaved = useCallback(() => { hasUnsavedChangesRef.current = false; lastSavedRef.current = new Date(); rerender(); }, [rerender]); const triggerSave = useCallback(async () => { if (isAutoSavingRef.current || !hasUnsavedChangesRef.current) { return; } isAutoSavingRef.current = true; rerender(); try { await onSave(); markAsSaved(); onSuccess?.(); } catch (error) { console.error("Auto-save failed:", error); onError?.(error instanceof Error ? error : new Error("Auto-save failed")); } finally { isAutoSavingRef.current = false; rerender(); } }, [onSave, onSuccess, onError, markAsSaved, rerender]); // Auto-save effect useEffect(() => { // Skip auto-save on initial render if (initialRenderRef.current) { initialRenderRef.current = false; return; } if (!enabled) { return; } // Clear existing timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); } // Mark as changed markAsChanged(); // Set new timeout for auto-save timeoutRef.current = setTimeout(() => { triggerSave(); }, delay); // Cleanup timeout on dependency change or unmount return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, dependencies); // Cleanup on unmount useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); return { isAutoSaving: isAutoSavingRef.current, lastSaved: lastSavedRef.current, hasUnsavedChanges: hasUnsavedChangesRef.current, triggerSave, markAsChanged, markAsSaved, }; };