@matthew.ngo/reform
Version:
A flexible and powerful React form management library with advanced validation, state observation, and multi-group support
283 lines (252 loc) • 8.61 kB
text/typescript
/**
* Hook for implementing conditional field rendering and validation in Reform forms.
* Provides utilities to show/hide and validate fields based on dynamic conditions.
*/
import { useMemo, useRef, useEffect } from 'react';
import { UseFormReturn, useWatch, Path } from 'react-hook-form';
import {
ConditionalFieldsManager,
GroupConditionalRenderProps,
ConditionalRenderProps,
} from './types';
import { useMemoizedCallback } from '../../common/useMemoizedCallback';
import { FormGroup } from '../../core/form/form-groups';
/**
* Props for the useConditionalFields hook
*
* @template T - The type of form data
*/
interface UseConditionalFieldsProps<T> {
/** React Hook Form methods */
methods: UseFormReturn<{ groups: FormGroup<T>[] }>;
}
/**
* Hook that provides utilities for conditional field rendering and validation
*
* @template T - The type of form data
* @param props - Hook configuration
* @returns Object with conditional field utilities
*/
export const useConditionalFields = <T extends Record<string, any>>({
methods,
}: UseConditionalFieldsProps<T>): ConditionalFieldsManager<T> => {
// Store methods reference to avoid unnecessary re-renders
const methodsRef = useRef(methods);
useEffect(() => {
methodsRef.current = methods;
}, [methods]);
// Watch all form values for conditional logic
const formValues = useWatch({
control: methods.control,
});
// Memoize form values to prevent unnecessary re-renders
const memoizedFormValues = useMemo(() => formValues, [
// Use JSON.stringify for complex objects to compare by value
JSON.stringify(
formValues?.groups?.map(group => ({
id: group.id,
data: group.data,
}))
),
]);
const when = useMemoizedCallback(
(
groupIndex: number,
condition: (groupData: T) => boolean,
options?: {
clearWhenHidden?: boolean;
validateWhenHidden?: boolean;
}
): GroupConditionalRenderProps<T> => {
const { clearWhenHidden = true, validateWhenHidden = false } =
options || {};
// Get the current group data
const groupData = memoizedFormValues?.groups?.[groupIndex]?.data as
| T
| undefined;
// If group doesn't exist, return default values
if (!groupData) {
return {
shouldShow: false,
shouldValidate: validateWhenHidden,
render: () => null,
registerIf: <K extends keyof T>(fieldName: K) => ({
name: `groups.${groupIndex}.data.${String(fieldName)}`,
onChange: () => {},
onBlur: () => {},
ref: () => {},
}),
validateIf: () => undefined,
};
}
// Evaluate the condition
const shouldShow = condition(groupData);
return {
shouldShow,
shouldValidate: shouldShow || validateWhenHidden,
/**
* Helper for JSX rendering that conditionally renders a component
*
* @param component - The React component to conditionally render
* @returns The component if condition is met, otherwise null
*/
render: (component: React.ReactNode) => {
return shouldShow ? component : null;
},
/**
* Helper for field registration that conditionally registers a field
*
* @param fieldName - The name of the field to register
* @param options - Registration options passed to React Hook Form
* @returns Either a real or dummy registration object
*/
registerIf: <K extends keyof T>(fieldName: K, options?: any) => {
if (shouldShow || validateWhenHidden) {
return methodsRef.current.register(
`groups.${groupIndex}.data.${String(fieldName)}` as Path<{
groups: FormGroup<T>[];
}>,
options
);
}
// If field is hidden and we should clear its value
if (clearWhenHidden) {
const fieldPath = `groups.${groupIndex}.data.${String(
fieldName
)}` as Path<{ groups: FormGroup<T>[] }>;
// Use null instead of undefined for clearer type compatibility
// or use the initial/default value for this field type if available
methodsRef.current.setValue(fieldPath, null as any, {
shouldValidate: false,
});
}
// Return a dummy register object when field is hidden
return {
name: `groups.${groupIndex}.data.${String(fieldName)}`,
onChange: () => {},
onBlur: () => {},
ref: () => {},
};
},
/**
* Helper for field validation that conditionally validates a field
*
* @param fieldName - The name of the field to validate
* @param validationFn - Function that validates the field value
* @returns Validation error message or undefined if valid/skipped
*/
validateIf: <K extends keyof T>(
fieldName: K,
validationFn: (value: T[K]) => string | undefined
) => {
if (!shouldShow && !validateWhenHidden) {
return undefined;
}
const value = groupData[fieldName];
return validationFn(value);
},
};
},
[memoizedFormValues]
);
const whenAny = useMemoizedCallback(
(
condition: (allGroups: FormGroup<T>[]) => boolean,
options?: {
clearWhenHidden?: boolean;
validateWhenHidden?: boolean;
}
): ConditionalRenderProps => {
const { clearWhenHidden = true, validateWhenHidden = false } =
options || {};
// Get all groups
const groups = memoizedFormValues?.groups as FormGroup<T>[] | undefined;
// If no groups exist, return default values
if (!groups || !Array.isArray(groups)) {
return {
shouldShow: false,
shouldValidate: validateWhenHidden,
render: () => null,
};
}
// Evaluate the condition
const shouldShow = condition(groups);
return {
shouldShow,
shouldValidate: shouldShow || validateWhenHidden,
/**
* Helper for JSX rendering that conditionally renders a component
*
* @param component - The React component to conditionally render
* @returns The component if condition is met, otherwise null
*/
render: (component: React.ReactNode) => {
return shouldShow ? component : null;
},
};
},
[memoizedFormValues]
);
const createDependencyTracker = useMemoizedCallback(
<K extends keyof T>(
groupIndex: number,
dependencies: K[],
effect: (values: Pick<T, K>) => void
): void => {
// Extract only the dependent values
const groupData = memoizedFormValues?.groups?.[groupIndex]?.data as
| T
| undefined;
if (groupData) {
const dependentValues = dependencies.reduce((acc, key) => {
acc[key] = groupData[key];
return acc;
}, {} as Pick<T, K>);
// Run the effect with the dependent values
effect(dependentValues);
}
},
[memoizedFormValues]
);
const whenCross = useMemoizedCallback(
(
condition: (formData: { groups: FormGroup<T>[] }) => boolean
): ConditionalRenderProps => {
// If no form values exist, return default values
if (!memoizedFormValues || !memoizedFormValues.groups) {
return {
shouldShow: false,
shouldValidate: false,
render: () => null,
};
}
// Create a properly typed version of the form data
const typedFormData = {
groups: memoizedFormValues.groups.map(group => ({
id: group.id || '', // Ensure id is not undefined
data: group.data || ({} as T), // Ensure data is not undefined
})) as FormGroup<T>[],
};
// Evaluate the condition with all form data
const shouldShow = condition(typedFormData);
return {
shouldShow,
shouldValidate: shouldShow,
render: (component: React.ReactNode) => {
return shouldShow ? component : null;
},
};
},
[memoizedFormValues]
);
// Memoize the return object to prevent unnecessary re-renders
return useMemo(
() => ({
when,
whenAny,
whenCross,
createDependencyTracker,
}),
[when, whenAny, whenCross, createDependencyTracker]
);
};