import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import isEqual from 'lodash/isEqual'; interface FormState< FormFields extends PlainValue, FieldErrors extends Record, > { /** * State for each field in the form. */ fields: { [K in keyof FormFields]: { hasChanged: boolean; initialValue: FormFields[K]; onChange: (value: React.SetStateAction) => void; value: FormFields[K]; error?: FieldErrors[K]; }; }; /** * Whether the form has changed from the initial values. */ hasChanged: boolean; /** * Whether the form is valid. * A form is valid if all fields pass validation. */ isValid: boolean; /** * Resets the form state to the initial values. */ reset: () => void; /** * Saves the form state by setting the initial values to the current values. */ save: () => void; } type PlainValue = AtomicValue | PlainArray | PlainObject; interface PlainObject { [key: string]: PlainValue; } interface PlainArray extends Array {} type AtomicValue = string | number | boolean | null | undefined; export type FormValidators< FormFields extends Record, FieldErrors extends Record, > = { [K in keyof FormFields]?: (value: FormFields[K]) => FieldErrors[K] | undefined; }; type InitialValues> = { [K in keyof FormFields]: FormFields[K]; }; type FormStateConfig< FormFields extends Record, FieldErrors extends Record, > = { /** * The initial values for the form fields. */ initialValues: InitialValues; /** * Whether to re-initialize the form state when the initial values change. */ enableReInitialize?: boolean; /** * Validator functions for the form fields. */ validators?: FormValidators; }; /** * Creates a form state object with fields and validation for a given set of form fields. */ export const useFormState = < FormFields extends Record, FieldErrors extends Record, >( config: FormStateConfig ): FormState => { const [initialValues, setInitialValues] = useState(config.initialValues); const [validators] = useState(config.validators); const [values, setValues] = useState(initialValues); const [errors, setErrors] = useState<{[K in keyof FormFields]?: FieldErrors[K]}>({}); useEffect(() => { if (config.enableReInitialize) { setInitialValues(config.initialValues); setValues(config.initialValues); setErrors({}); } }, [config.enableReInitialize, config.initialValues]); const setValue = useCallback( (name: K, value: React.SetStateAction) => { setValues(old => ({ ...old, [name]: typeof value === 'function' ? value(old[name]) : value, })); }, [] ); const setError = useCallback( (name: K, error: string | undefined) => { setErrors(old => ({...old, [name]: error})); }, [] ); /** * Validates a field by running the field's validator function. */ const validateField = useCallback( (name: K, value: FormFields[K]) => { const validator = validators?.[name]; return validator?.(value); }, [validators] ); const handleFieldChange = useCallback( (name: K, value: React.SetStateAction) => { setValue(name, old => { const newValue = typeof value === 'function' ? value(old) : value; const error = validateField(name, newValue); setError(name, error); return newValue; }); }, [setError, setValue, validateField] ); const changeHandlers = useMemo(() => { const result: { [K in keyof FormFields]: (value: React.SetStateAction) => void; } = {} as any; for (const name in initialValues) { result[name] = (value: React.SetStateAction) => handleFieldChange(name, value); } return result; }, [handleFieldChange, initialValues]); const fields = useMemo(() => { const result: FormState['fields'] = {} as any; for (const name in initialValues) { result[name] = { value: values[name], onChange: changeHandlers[name], error: errors[name], hasChanged: values[name] !== initialValues[name], initialValue: initialValues[name], }; } return result; }, [changeHandlers, errors, initialValues, values]); return { fields, isValid: Object.values(errors).every(error => !error), hasChanged: Object.entries(values).some( ([name, value]) => !isEqual(value, initialValues[name]) ), save: () => { setInitialValues(values); }, reset: () => { setValues(initialValues); setErrors({}); }, }; }; /** * Creates a form context and hooks for a form with a given set of fields to enable type-safe form handling. */ export const createForm = < FormFields extends Record, FieldErrors extends Record = Record< keyof FormFields, string | undefined >, >({ validators, }: { validators?: FormValidators; }) => { const FormContext = createContext | undefined>( undefined ); function FormProvider({ children, formState, }: { children: React.ReactNode; formState: FormState; }) { return {children}; } const useFormField = (name: K) => { const formState = useContext(FormContext); if (!formState) { throw new Error('useFormField must be used within a FormProvider'); } return formState.fields[name]; }; return { useFormState: ( config: Omit, 'validators'> ) => useFormState({...config, validators}), FormProvider, useFormField, }; };