formContext.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useMemo,
  7. useState,
  8. } from 'react';
  9. import isEqual from 'lodash/isEqual';
  10. interface FormState<
  11. FormFields extends PlainValue,
  12. FieldErrors extends Record<keyof FormFields, any>,
  13. > {
  14. /**
  15. * State for each field in the form.
  16. */
  17. fields: {
  18. [K in keyof FormFields]: {
  19. hasChanged: boolean;
  20. initialValue: FormFields[K];
  21. onChange: (value: React.SetStateAction<FormFields[K]>) => void;
  22. value: FormFields[K];
  23. error?: FieldErrors[K];
  24. };
  25. };
  26. /**
  27. * Whether the form has changed from the initial values.
  28. */
  29. hasChanged: boolean;
  30. /**
  31. * Whether the form is valid.
  32. * A form is valid if all fields pass validation.
  33. */
  34. isValid: boolean;
  35. /**
  36. * Resets the form state to the initial values.
  37. */
  38. reset: () => void;
  39. /**
  40. * Saves the form state by setting the initial values to the current values.
  41. */
  42. save: () => void;
  43. }
  44. type PlainValue = AtomicValue | PlainArray | PlainObject;
  45. interface PlainObject {
  46. [key: string]: PlainValue;
  47. }
  48. interface PlainArray extends Array<PlainValue> {}
  49. type AtomicValue = string | number | boolean | null | undefined;
  50. export type FormValidators<
  51. FormFields extends Record<string, PlainValue>,
  52. FieldErrors extends Record<keyof FormFields, any>,
  53. > = {
  54. [K in keyof FormFields]?: (value: FormFields[K]) => FieldErrors[K] | undefined;
  55. };
  56. type InitialValues<FormFields extends Record<string, any>> = {
  57. [K in keyof FormFields]: FormFields[K];
  58. };
  59. type FormStateConfig<
  60. FormFields extends Record<string, PlainValue>,
  61. FieldErrors extends Record<keyof FormFields, any>,
  62. > = {
  63. /**
  64. * The initial values for the form fields.
  65. */
  66. initialValues: InitialValues<FormFields>;
  67. /**
  68. * Whether to re-initialize the form state when the initial values change.
  69. */
  70. enableReInitialize?: boolean;
  71. /**
  72. * Validator functions for the form fields.
  73. */
  74. validators?: FormValidators<FormFields, FieldErrors>;
  75. };
  76. /**
  77. * Creates a form state object with fields and validation for a given set of form fields.
  78. */
  79. export const useFormState = <
  80. FormFields extends Record<string, PlainValue>,
  81. FieldErrors extends Record<keyof FormFields, any>,
  82. >(
  83. config: FormStateConfig<FormFields, FieldErrors>
  84. ): FormState<FormFields, FieldErrors> => {
  85. const [initialValues, setInitialValues] = useState(config.initialValues);
  86. const [validators] = useState(config.validators);
  87. const [values, setValues] = useState(initialValues);
  88. const [errors, setErrors] = useState<{[K in keyof FormFields]?: FieldErrors[K]}>({});
  89. useEffect(() => {
  90. if (config.enableReInitialize) {
  91. setInitialValues(config.initialValues);
  92. setValues(config.initialValues);
  93. setErrors({});
  94. }
  95. }, [config.enableReInitialize, config.initialValues]);
  96. const setValue = useCallback(
  97. <K extends keyof FormFields>(name: K, value: React.SetStateAction<FormFields[K]>) => {
  98. setValues(old => ({
  99. ...old,
  100. [name]: typeof value === 'function' ? value(old[name]) : value,
  101. }));
  102. },
  103. []
  104. );
  105. const setError = useCallback(
  106. <K extends keyof FormFields>(name: K, error: string | undefined) => {
  107. setErrors(old => ({...old, [name]: error}));
  108. },
  109. []
  110. );
  111. /**
  112. * Validates a field by running the field's validator function.
  113. */
  114. const validateField = useCallback(
  115. <K extends keyof FormFields>(name: K, value: FormFields[K]) => {
  116. const validator = validators?.[name];
  117. return validator?.(value);
  118. },
  119. [validators]
  120. );
  121. const handleFieldChange = useCallback(
  122. <K extends keyof FormFields>(name: K, value: React.SetStateAction<FormFields[K]>) => {
  123. setValue(name, old => {
  124. const newValue = typeof value === 'function' ? value(old) : value;
  125. const error = validateField(name, newValue);
  126. setError(name, error);
  127. return newValue;
  128. });
  129. },
  130. [setError, setValue, validateField]
  131. );
  132. const changeHandlers = useMemo(() => {
  133. const result: {
  134. [K in keyof FormFields]: (value: React.SetStateAction<FormFields[K]>) => void;
  135. } = {} as any;
  136. for (const name in initialValues) {
  137. result[name] = (value: React.SetStateAction<FormFields[typeof name]>) =>
  138. handleFieldChange(name, value);
  139. }
  140. return result;
  141. }, [handleFieldChange, initialValues]);
  142. const fields = useMemo(() => {
  143. const result: FormState<FormFields, FieldErrors>['fields'] = {} as any;
  144. for (const name in initialValues) {
  145. result[name] = {
  146. value: values[name],
  147. onChange: changeHandlers[name],
  148. error: errors[name],
  149. hasChanged: values[name] !== initialValues[name],
  150. initialValue: initialValues[name],
  151. };
  152. }
  153. return result;
  154. }, [changeHandlers, errors, initialValues, values]);
  155. return {
  156. fields,
  157. isValid: Object.values(errors).every(error => !error),
  158. hasChanged: Object.entries(values).some(
  159. ([name, value]) => !isEqual(value, initialValues[name])
  160. ),
  161. save: () => {
  162. setInitialValues(values);
  163. },
  164. reset: () => {
  165. setValues(initialValues);
  166. setErrors({});
  167. },
  168. };
  169. };
  170. /**
  171. * Creates a form context and hooks for a form with a given set of fields to enable type-safe form handling.
  172. */
  173. export const createForm = <
  174. FormFields extends Record<string, PlainValue>,
  175. FieldErrors extends Record<keyof FormFields, any> = Record<
  176. keyof FormFields,
  177. string | undefined
  178. >,
  179. >({
  180. validators,
  181. }: {
  182. validators?: FormValidators<FormFields, FieldErrors>;
  183. }) => {
  184. const FormContext = createContext<FormState<FormFields, FieldErrors> | undefined>(
  185. undefined
  186. );
  187. function FormProvider({
  188. children,
  189. formState,
  190. }: {
  191. children: React.ReactNode;
  192. formState: FormState<FormFields, FieldErrors>;
  193. }) {
  194. return <FormContext.Provider value={formState}>{children}</FormContext.Provider>;
  195. }
  196. const useFormField = <K extends keyof FormFields>(name: K) => {
  197. const formState = useContext(FormContext);
  198. if (!formState) {
  199. throw new Error('useFormField must be used within a FormProvider');
  200. }
  201. return formState.fields[name];
  202. };
  203. return {
  204. useFormState: (
  205. config: Omit<FormStateConfig<FormFields, FieldErrors>, 'validators'>
  206. ) => useFormState<FormFields, FieldErrors>({...config, validators}),
  207. FormProvider,
  208. useFormField,
  209. };
  210. };