123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useState,
- } from 'react';
- import isEqual from 'lodash/isEqual';
- interface FormState<
- FormFields extends PlainValue,
- FieldErrors extends Record<keyof FormFields, any>,
- > {
- /**
- * State for each field in the form.
- */
- fields: {
- [K in keyof FormFields]: {
- hasChanged: boolean;
- initialValue: FormFields[K];
- onChange: (value: React.SetStateAction<FormFields[K]>) => 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<PlainValue> {}
- type AtomicValue = string | number | boolean | null | undefined;
- export type FormValidators<
- FormFields extends Record<string, PlainValue>,
- FieldErrors extends Record<keyof FormFields, any>,
- > = {
- [K in keyof FormFields]?: (value: FormFields[K]) => FieldErrors[K] | undefined;
- };
- type InitialValues<FormFields extends Record<string, any>> = {
- [K in keyof FormFields]: FormFields[K];
- };
- type FormStateConfig<
- FormFields extends Record<string, PlainValue>,
- FieldErrors extends Record<keyof FormFields, any>,
- > = {
- /**
- * The initial values for the form fields.
- */
- initialValues: InitialValues<FormFields>;
- /**
- * Whether to re-initialize the form state when the initial values change.
- */
- enableReInitialize?: boolean;
- /**
- * Validator functions for the form fields.
- */
- validators?: FormValidators<FormFields, FieldErrors>;
- };
- /**
- * Creates a form state object with fields and validation for a given set of form fields.
- */
- export const useFormState = <
- FormFields extends Record<string, PlainValue>,
- FieldErrors extends Record<keyof FormFields, any>,
- >(
- config: FormStateConfig<FormFields, FieldErrors>
- ): FormState<FormFields, FieldErrors> => {
- 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(
- <K extends keyof FormFields>(name: K, value: React.SetStateAction<FormFields[K]>) => {
- setValues(old => ({
- ...old,
- [name]: typeof value === 'function' ? value(old[name]) : value,
- }));
- },
- []
- );
- const setError = useCallback(
- <K extends keyof FormFields>(name: K, error: string | undefined) => {
- setErrors(old => ({...old, [name]: error}));
- },
- []
- );
- /**
- * Validates a field by running the field's validator function.
- */
- const validateField = useCallback(
- <K extends keyof FormFields>(name: K, value: FormFields[K]) => {
- const validator = validators?.[name];
- return validator?.(value);
- },
- [validators]
- );
- const handleFieldChange = useCallback(
- <K extends keyof FormFields>(name: K, value: React.SetStateAction<FormFields[K]>) => {
- 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<FormFields[K]>) => void;
- } = {} as any;
- for (const name in initialValues) {
- result[name] = (value: React.SetStateAction<FormFields[typeof name]>) =>
- handleFieldChange(name, value);
- }
- return result;
- }, [handleFieldChange, initialValues]);
- const fields = useMemo(() => {
- const result: FormState<FormFields, FieldErrors>['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<string, PlainValue>,
- FieldErrors extends Record<keyof FormFields, any> = Record<
- keyof FormFields,
- string | undefined
- >,
- >({
- validators,
- }: {
- validators?: FormValidators<FormFields, FieldErrors>;
- }) => {
- const FormContext = createContext<FormState<FormFields, FieldErrors> | undefined>(
- undefined
- );
- function FormProvider({
- children,
- formState,
- }: {
- children: React.ReactNode;
- formState: FormState<FormFields, FieldErrors>;
- }) {
- return <FormContext.Provider value={formState}>{children}</FormContext.Provider>;
- }
- const useFormField = <K extends keyof FormFields>(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<FormStateConfig<FormFields, FieldErrors>, 'validators'>
- ) => useFormState<FormFields, FieldErrors>({...config, validators}),
- FormProvider,
- useFormField,
- };
- };
|