UNPKG

@matthew.ngo/reform

Version:

A flexible and powerful React form management library with advanced validation, state observation, and multi-group support

329 lines (290 loc) 8.9 kB
import { useEffect, useRef, useState, useMemo } from "react"; import { ReformReturn } from "../../types"; import { FormPersistenceConfig, FormPersistenceData, FormPersistenceReturn, StorageProvider, } from "./types"; import { useMemoizedCallback } from "../../common/useMemoizedCallback"; /** * Default storage provider using localStorage */ class LocalStorageProvider<T> implements StorageProvider<T> { async save(key: string, data: FormPersistenceData<T>): Promise<void> { try { localStorage.setItem(key, JSON.stringify(data)); } catch (error) { console.error("Error saving form state to localStorage:", error); throw error; } } async load(key: string): Promise<FormPersistenceData<T> | null> { try { const data = localStorage.getItem(key); if (!data) return null; return JSON.parse(data) as FormPersistenceData<T>; } catch (error) { console.error("Error loading form state from localStorage:", error); return null; } } async remove(key: string): Promise<void> { try { localStorage.removeItem(key); } catch (error) { console.error("Error removing form state from localStorage:", error); throw error; } } } /** * Hook for managing form state persistence * * @template T - The type of form data * @param reform - The Reform hook return value * @param config - Configuration for form persistence * @returns Form persistence state and methods */ export const useFormPersistence = <T extends Record<string, any>>( reform: ReformReturn<T>, config: FormPersistenceConfig<T> = {} ): FormPersistenceReturn<T> => { // Store reform reference to avoid unnecessary re-renders const reformRef = useRef(reform); useEffect(() => { reformRef.current = reform; }, [reform]); // Memoize config to prevent unnecessary re-renders const memoizedConfig = useMemo( () => ({ enabled: config.enabled ?? false, storageKey: config.storageKey ?? "reform-form-state", storageProvider: config.storageProvider ?? new LocalStorageProvider<T>(), autoSave: config.autoSave ?? false, autoSaveInterval: config.autoSaveInterval ?? 5000, debounceAutoSave: config.debounceAutoSave ?? true, debounceDelay: config.debounceDelay ?? 500, onBeforeSave: config.onBeforeSave, onAfterSave: config.onAfterSave, onAfterRestore: config.onAfterRestore, metadata: config.metadata, }), [ config.enabled, config.storageKey, config.storageProvider, config.autoSave, config.autoSaveInterval, config.debounceAutoSave, config.debounceDelay, config.onBeforeSave, config.onAfterSave, config.onAfterRestore, // Use JSON.stringify for complex objects to compare by value typeof config.metadata === "function" ? config.metadata : JSON.stringify(config.metadata), ] ); // Extract config with defaults const { enabled, storageKey, storageProvider, autoSave, autoSaveInterval, debounceAutoSave, debounceDelay, onBeforeSave, onAfterSave, onAfterRestore, metadata, } = memoizedConfig; // State const [isSaving, setIsSaving] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false); const [lastSaved, setLastSaved] = useState<number | null>(null); const [isAutoSaveEnabled, setIsAutoSaveEnabled] = useState<boolean>(autoSave); // Refs for timers and debounce const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null); const debounceTimerRef = useRef<NodeJS.Timeout | null>(null); const isInitialLoadRef = useRef<boolean>(true); // Clear auto-save timer on unmount useEffect(() => { return () => { if (autoSaveTimerRef.current) { clearInterval(autoSaveTimerRef.current); } if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } }; }, []); // Get metadata to save - memoized to prevent unnecessary re-renders const getMetadata = useMemoizedCallback(() => { if (typeof metadata === "function") { return metadata(); } return metadata || {}; }, [metadata]); // Save form state to storage - memoized to prevent unnecessary re-renders const saveState = useMemoizedCallback(async (): Promise<void> => { if (!enabled) return; try { setIsSaving(true); const groups = reformRef.current.getGroups(); // Check if we should proceed with saving if (onBeforeSave) { const shouldProceed = await Promise.resolve(onBeforeSave(groups)); if (!shouldProceed) { return; } } // Prepare data to save const dataToSave: FormPersistenceData<T> = { groups, timestamp: Date.now(), version: "1.0", // Version for potential future migrations metadata: getMetadata(), }; // Save to storage await storageProvider.save(storageKey, dataToSave); // Update last saved timestamp setLastSaved(dataToSave.timestamp); // Call after save callback if (onAfterSave) { onAfterSave(groups); } } catch (error) { console.error("Error saving form state:", error); } finally { setIsSaving(false); } }, [ enabled, storageProvider, storageKey, getMetadata, onBeforeSave, onAfterSave, ]); // Load form state from storage - memoized to prevent unnecessary re-renders const loadState = useMemoizedCallback(async (): Promise<void> => { if (!enabled) return; try { setIsLoading(true); // Load from storage const data = await storageProvider.load(storageKey); if (data && data.groups) { // Update form with loaded data reformRef.current.setGroups(data.groups); // Update last saved timestamp setLastSaved(data.timestamp); // Call after restore callback if (onAfterRestore) { onAfterRestore(data.groups); } } } catch (error) { console.error("Error loading form state:", error); } finally { setIsLoading(false); } }, [enabled, storageProvider, storageKey, onAfterRestore]); // Clear saved form state - memoized to prevent unnecessary re-renders const clearState = useMemoizedCallback(async (): Promise<void> => { if (!enabled) return; try { await storageProvider.remove(storageKey); setLastSaved(null); } catch (error) { console.error("Error clearing form state:", error); } }, [enabled, storageProvider, storageKey]); // Reset auto-save timer - memoized to prevent unnecessary re-renders const resetAutoSaveTimer = useMemoizedCallback(() => { if (!isAutoSaveEnabled) return; if (autoSaveTimerRef.current) { clearInterval(autoSaveTimerRef.current); } autoSaveTimerRef.current = setInterval(() => { saveState(); }, autoSaveInterval); }, [isAutoSaveEnabled, autoSaveInterval, saveState]); // Handle form changes for auto-save useEffect(() => { if (!enabled || !isAutoSaveEnabled) return; // Skip the initial load if (isInitialLoadRef.current) { isInitialLoadRef.current = false; return; } // Debounce auto-save if (debounceAutoSave) { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } debounceTimerRef.current = setTimeout(() => { saveState(); }, debounceDelay); } else { // Reset the interval timer on each change resetAutoSaveTimer(); } return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } }; }, [ enabled, isAutoSaveEnabled, debounceAutoSave, debounceDelay, resetAutoSaveTimer, saveState, reform.getGroups, // This dependency will trigger the effect when form data changes ]); // Initialize auto-save timer useEffect(() => { if (enabled && isAutoSaveEnabled && !debounceAutoSave) { resetAutoSaveTimer(); } return () => { if (autoSaveTimerRef.current) { clearInterval(autoSaveTimerRef.current); } }; }, [enabled, isAutoSaveEnabled, debounceAutoSave, resetAutoSaveTimer]); // Initial load from storage useEffect(() => { if (enabled) { loadState(); } }, [enabled, loadState]); // Memoize the return object to prevent unnecessary re-renders return useMemo( () => ({ isSaving, isLoading, lastSaved, saveState, loadState, clearState, resetAutoSaveTimer, setAutoSave: setIsAutoSaveEnabled, isAutoSaveEnabled, }), [ isSaving, isLoading, lastSaved, saveState, loadState, clearState, resetAutoSaveTimer, isAutoSaveEnabled, ] ); };