@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
text/typescript
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,
]
);
};