@matthew.ngo/reform
Version:
A flexible and powerful React form management library with advanced validation, state observation, and multi-group support
279 lines (245 loc) • 8.82 kB
text/typescript
import { useEffect, useRef, useCallback } from 'react';
import { UseFormWatch } from 'react-hook-form';
import { FieldWatchConfig, FieldWatcherReturn } from './types';
import { useMemoizedCallback } from '../../common/useMemoizedCallback';
import { FormGroup } from '../../core/form/form-groups';
/**
* Hook for watching specific field changes across form groups
*
* @template T - Form data type
* @param watch - React Hook Form watch function
* @returns Object with field watching utilities
*
* @example
* const form = useReform(config);
* const { watch } = form.formMethods;
* const fieldWatcher = useFieldWatcher<FormData>(watch);
*
* // Watch email field changes
* useEffect(() => {
* const unsubscribe = fieldWatcher.watchField({
* field: 'email',
* callback: (value) => validateEmailAsync(value),
* debounce: 500
* });
*
* return unsubscribe;
* }, []);
*/
export const useFieldWatcher = <T extends Record<string, any>>(
watch: UseFormWatch<{ groups: FormGroup<T>[] }>
): FieldWatcherReturn<T> => {
// Store active watchers
const watchersRef = useRef<
Map<
string,
{
config: FieldWatchConfig<T, any>;
previousValues: Record<number, any>;
timeoutId?: NodeJS.Timeout;
}
>
>(new Map());
// Store latest form data
const formDataRef = useRef<{ groups: FormGroup<T>[] } | null>(null);
// Create a unique key for each watcher
const createWatcherKey = (field: keyof T, groupIndex?: number) => {
return `${String(field)}${
groupIndex !== undefined ? `:${groupIndex}` : ''
}`;
};
// Process field changes
const processFieldChange = useCallback(
<K extends keyof T>(
field: K,
groupIndex: number,
value: T[K],
formData: { groups: FormGroup<T>[] }
) => {
// Check for watchers that match this field and group
const specificKey = createWatcherKey(field, groupIndex);
const globalKey = createWatcherKey(field);
// Update form data reference
formDataRef.current = formData;
// Process specific group watcher
if (watchersRef.current.has(specificKey)) {
const watcher = watchersRef.current.get(specificKey)!;
const previousValue = watcher.previousValues[groupIndex];
// Only trigger if value actually changed
if (value !== previousValue) {
const { callback, debounce } = watcher.config;
// Clear any existing timeout
if (watcher.timeoutId) {
clearTimeout(watcher.timeoutId);
}
// Execute callback with debounce if specified
if (debounce && debounce > 0) {
watcher.timeoutId = setTimeout(() => {
callback(value, previousValue, groupIndex, formData);
// Update previous value after callback
watcher.previousValues[groupIndex] = value;
}, debounce);
} else {
// Execute immediately
callback(value, previousValue, groupIndex, formData);
// Update previous value after callback
watcher.previousValues[groupIndex] = value;
}
}
}
// Process global field watcher (for all groups)
if (watchersRef.current.has(globalKey)) {
const watcher = watchersRef.current.get(globalKey)!;
const previousValue = watcher.previousValues[groupIndex];
// Only trigger if value actually changed
if (value !== previousValue) {
const { callback, debounce } = watcher.config;
// Clear any existing timeout
if (watcher.timeoutId) {
clearTimeout(watcher.timeoutId);
}
// Execute callback with debounce if specified
if (debounce && debounce > 0) {
watcher.timeoutId = setTimeout(() => {
callback(value, previousValue, groupIndex, formData);
// Update previous value after callback
watcher.previousValues[groupIndex] = value;
}, debounce);
} else {
// Execute immediately
callback(value, previousValue, groupIndex, formData);
// Update previous value after callback
watcher.previousValues[groupIndex] = value;
}
}
}
},
[]
);
// Set up the watch subscription
useEffect(() => {
// Subscribe to form changes
const subscription = watch((formData, { name, type }) => {
// Only process when we have groups data
if (!formData?.groups) return;
// Check if the change is related to a field
if (type === 'change' && name && name.includes('.data.')) {
// Extract group index and field name from the field path
const match = name.match(/groups\.(\d+)\.data\.(.+)/);
if (match && match[1] && match[2]) {
const groupIndex = parseInt(match[1], 10);
const fieldPath = match[2];
// Find the field that matches this path
// This is a simplification - for nested fields you'd need more complex logic
const field = fieldPath.split('.')[0] as keyof T;
const group = formData.groups[groupIndex];
if (!group || !group.id) return;
// Get the current value
const currentValue = group.data?.[field as string];
if (currentValue !== undefined) {
// Process the field change
processFieldChange(
field,
groupIndex,
currentValue,
formData as { groups: FormGroup<T>[] }
);
}
}
}
});
// Clean up subscription
return () => subscription.unsubscribe();
}, [watch, processFieldChange]);
// Register a new field watcher
const watchField = useMemoizedCallback(
<K extends keyof T>(config: FieldWatchConfig<T, K>) => {
const { field, groupIndex, immediate = false } = config;
const key = createWatcherKey(field, groupIndex);
// Store the watcher configuration
watchersRef.current.set(key, {
config,
previousValues: {},
});
// Trigger immediately if requested and we have form data
if (immediate && formDataRef.current?.groups) {
const formData = formDataRef.current;
if (groupIndex !== undefined) {
// Trigger for specific group
const value = formData.groups[groupIndex]?.data?.[field as string];
if (value !== undefined) {
processFieldChange(field, groupIndex, value, formData);
}
} else {
// Trigger for all groups
formData.groups.forEach((group, idx) => {
const value = group.data?.[field as string];
if (value !== undefined) {
processFieldChange(field, idx, value, formData);
}
});
}
}
// Return unsubscribe function
return () => {
const watcher = watchersRef.current.get(key);
if (watcher?.timeoutId) {
clearTimeout(watcher.timeoutId);
}
watchersRef.current.delete(key);
};
},
[processFieldChange]
);
// Register multiple field watchers
const watchFields = useMemoizedCallback(
<K extends keyof T>(configs: FieldWatchConfig<T, K>[]) => {
// Register each watcher
const unsubscribers = configs.map(config => watchField(config));
// Return combined unsubscribe function
return () => {
unsubscribers.forEach(unsubscribe => unsubscribe());
};
},
[watchField]
);
// Get current field value
const getFieldValue = useMemoizedCallback(
<K extends keyof T>(field: K, groupIndex: number) => {
if (formDataRef.current?.groups?.[groupIndex]?.data) {
return formDataRef.current.groups[groupIndex].data[field as string];
}
return undefined;
},
[]
);
// Manually trigger field watchers
const triggerFieldWatch = useMemoizedCallback(
<K extends keyof T>(field: K, groupIndex?: number) => {
if (!formDataRef.current?.groups) return;
const formData = formDataRef.current;
if (groupIndex !== undefined) {
// Trigger for specific group
const value = formData.groups[groupIndex]?.data?.[field as string];
if (value !== undefined) {
processFieldChange(field, groupIndex, value, formData);
}
} else {
// Trigger for all groups
formData.groups.forEach((group, idx) => {
const value = group.data?.[field as string];
if (value !== undefined) {
processFieldChange(field, idx, value, formData);
}
});
}
},
[processFieldChange]
);
return {
watchField,
watchFields,
getFieldValue,
triggerFieldWatch,
};
};