aboutsummaryrefslogtreecommitdiffstats
path: root/src/utils
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-03 19:34:16 +0100
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:15:27 +0100
commitddd45e29745b73e7fe1684e197dcff598b375644 (patch)
tree8bf01305b5c0d163c52a7dce747ed7a4a4650acb /src/utils
parent5d3e8a4d0c2ce2ad8f22df857ab3ce54fcfc38ac (diff)
feat(hooks): add an useForm hook
* add two "sub"-hooks: useFormValues and useFormSubmit (that can be used independently) * handle initial data * handle custom submit callback * handle data validation * handle submit status
Diffstat (limited to 'src/utils')
-rw-r--r--src/utils/hooks/index.ts1
-rw-r--r--src/utils/hooks/use-form/index.ts3
-rw-r--r--src/utils/hooks/use-form/use-form-submit/index.ts1
-rw-r--r--src/utils/hooks/use-form/use-form-submit/use-form-submit.test.ts163
-rw-r--r--src/utils/hooks/use-form/use-form-submit/use-form-submit.ts122
-rw-r--r--src/utils/hooks/use-form/use-form-values/index.ts1
-rw-r--r--src/utils/hooks/use-form/use-form-values/use-form-values.test.ts69
-rw-r--r--src/utils/hooks/use-form/use-form-values/use-form-values.ts69
-rw-r--r--src/utils/hooks/use-form/use-form.test.ts225
-rw-r--r--src/utils/hooks/use-form/use-form.ts134
10 files changed, 788 insertions, 0 deletions
diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts
index 9181b6a..9cc2b0f 100644
--- a/src/utils/hooks/index.ts
+++ b/src/utils/hooks/index.ts
@@ -5,6 +5,7 @@ export * from './use-boolean';
export * from './use-breadcrumb';
export * from './use-comments';
export * from './use-data-from-api';
+export * from './use-form';
export * from './use-github-api';
export * from './use-headings-tree';
export * from './use-is-mounted';
diff --git a/src/utils/hooks/use-form/index.ts b/src/utils/hooks/use-form/index.ts
new file mode 100644
index 0000000..9febc8b
--- /dev/null
+++ b/src/utils/hooks/use-form/index.ts
@@ -0,0 +1,3 @@
+export * from './use-form';
+export * from './use-form-submit';
+export * from './use-form-values';
diff --git a/src/utils/hooks/use-form/use-form-submit/index.ts b/src/utils/hooks/use-form/use-form-submit/index.ts
new file mode 100644
index 0000000..f7f5bdf
--- /dev/null
+++ b/src/utils/hooks/use-form/use-form-submit/index.ts
@@ -0,0 +1 @@
+export * from './use-form-submit';
diff --git a/src/utils/hooks/use-form/use-form-submit/use-form-submit.test.ts b/src/utils/hooks/use-form/use-form-submit/use-form-submit.test.ts
new file mode 100644
index 0000000..cb0da16
--- /dev/null
+++ b/src/utils/hooks/use-form/use-form-submit/use-form-submit.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { act, renderHook } from '@testing-library/react';
+import type { FormEvent } from 'react';
+import { type FormSubmitValidation, useFormSubmit } from './use-form-submit';
+
+const generateSubmitEvent = () =>
+ new Event('submit', {
+ bubbles: true,
+ cancelable: true,
+ }) as unknown as FormEvent<HTMLFormElement>;
+
+describe('useFormSubmit', () => {
+ const data = { foo: 'tempore', bar: false, baz: 42 };
+ const messages = { error: 'Error', success: 'Success' };
+
+ it('can submit the provided data', async () => {
+ const { result } = renderHook(() => useFormSubmit(data));
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ expect(result.current.messages).toBeNull();
+ expect(result.current.submitStatus).toBe('IDLE');
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(result.current.messages).toBeNull();
+ expect(result.current.submitStatus).toBe('SUCCEEDED');
+ });
+
+ it('can use a callback to handle submit', async () => {
+ const callback = jest.fn((_data) => undefined);
+ const { result } = renderHook(() =>
+ useFormSubmit(data, { submit: callback })
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(5);
+
+ expect(callback).not.toHaveBeenCalled();
+ expect(result.current.messages).toBeNull();
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith(data);
+ expect(result.current.messages).toBeNull();
+ });
+
+ it('can use a callback that fails validating data on submit', async () => {
+ const callback = jest.fn(
+ (values: typeof data): FormSubmitValidation<typeof data> => {
+ return {
+ messages,
+ validator: () => values.bar,
+ };
+ }
+ );
+ const { result } = renderHook(() =>
+ useFormSubmit(data, { submit: callback })
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(6);
+
+ expect(callback).not.toHaveBeenCalled();
+ expect(result.current.messages).toBeNull();
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith(data);
+ expect(result.current.submitStatus).toBe('FAILED');
+ expect(result.current.messages).toBe(messages);
+ });
+
+ it('can use a callback that succeeds validating data on submit', async () => {
+ const callback = jest.fn(
+ (values: typeof data): FormSubmitValidation<typeof data> => {
+ return {
+ messages,
+ validator: () => !values.bar,
+ };
+ }
+ );
+ const { result } = renderHook(() =>
+ useFormSubmit(data, { submit: callback })
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(6);
+
+ expect(callback).not.toHaveBeenCalled();
+ expect(result.current.messages).toBeNull();
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith(data);
+ expect(result.current.submitStatus).toBe('SUCCEEDED');
+ expect(result.current.messages).toBe(messages);
+ });
+
+ it('can call an onSuccess callback on success', async () => {
+ const callback = jest.fn();
+ const { result } = renderHook(() =>
+ useFormSubmit(data, { onSuccess: callback })
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(6);
+
+ expect(callback).not.toHaveBeenCalled();
+ expect(result.current.messages).toBeNull();
+ expect(result.current.submitStatus).toBe('IDLE');
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(result.current.messages).toBeNull();
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(result.current.submitStatus).toBe('SUCCEEDED');
+ });
+
+ it('can call an onFailure callback on failure', async () => {
+ const handlers = {
+ onFailure: jest.fn(),
+ submit: jest.fn(
+ (values: typeof data): FormSubmitValidation<typeof data> => {
+ return {
+ messages,
+ validator: () => values.bar,
+ };
+ }
+ ),
+ };
+ const { result } = renderHook(() => useFormSubmit(data, handlers));
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(6);
+
+ expect(handlers.onFailure).not.toHaveBeenCalled();
+ expect(result.current.messages).toBeNull();
+ expect(result.current.submitStatus).toBe('IDLE');
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(result.current.messages).toBe(messages);
+ expect(handlers.onFailure).toHaveBeenCalledTimes(1);
+ expect(result.current.submitStatus).toBe('FAILED');
+ });
+});
diff --git a/src/utils/hooks/use-form/use-form-submit/use-form-submit.ts b/src/utils/hooks/use-form/use-form-submit/use-form-submit.ts
new file mode 100644
index 0000000..8d02395
--- /dev/null
+++ b/src/utils/hooks/use-form/use-form-submit/use-form-submit.ts
@@ -0,0 +1,122 @@
+import { useCallback, useState, type FormEvent } from 'react';
+import type { DataValidator, Maybe, Nullable } from '../../../../types';
+
+export type FormSubmitMessages = {
+ /**
+ * The message to use on error.
+ */
+ error: string;
+ /**
+ * The message to use on success.
+ */
+ success: string;
+};
+
+export type FormSubmitValidation<T> = {
+ /**
+ * A callback to handle submit validation.
+ */
+ validator: DataValidator<T>;
+ /**
+ * The messages to use on failure or success.
+ */
+ messages: Partial<FormSubmitMessages>;
+};
+
+export type FormSubmitHandler<T> = (
+ data: T
+) => Maybe<FormSubmitValidation<T>> | Promise<Maybe<FormSubmitValidation<T>>>;
+
+export type FormSubmitStatus = 'IDLE' | 'PENDING' | 'FAILED' | 'SUCCEEDED';
+
+export type FormHandlers<T extends Record<string, unknown>> = {
+ /**
+ * A callback function to handle submit failure.
+ */
+ onFailure: () => void;
+ /**
+ * A callback function to handle submit success.
+ */
+ onSuccess: () => void;
+ /**
+ * A callback function to handle submit.
+ */
+ submit: FormSubmitHandler<T>;
+};
+
+export type UseFormSubmitReturn = {
+ /**
+ * The message to use on submit failure or success.
+ */
+ messages: Nullable<Partial<FormSubmitMessages>>;
+ /**
+ * A method to handle form submit.
+ *
+ * @param {FormEvent<HTMLFormElement>} e - The event.
+ * @returns {Promise<void>}
+ */
+ submit: (e: FormEvent<HTMLFormElement>) => Promise<void>;
+ /**
+ * The submit status.
+ */
+ submitStatus: FormSubmitStatus;
+};
+
+/**
+ * React hook to handle form submit.
+ *
+ * @template {object} T - The object keys should match the fields name.
+ * @param {T} data - The form values.
+ * @param {Partial<FormHandlers<T>>} handlers - The submit handlers.
+ * @returns {UseFormSubmitReturn} A submit method, the status and messages.
+ */
+export const useFormSubmit = <T extends Record<string, unknown>>(
+ data: T,
+ handlers?: Partial<FormHandlers<T>>
+): UseFormSubmitReturn => {
+ const { onFailure, onSuccess, submit: submitHandler } = handlers ?? {};
+ const [messages, setMessages] =
+ useState<Nullable<Partial<FormSubmitMessages>>>(null);
+ const [submitStatus, setSubmitStatus] = useState<FormSubmitStatus>('IDLE');
+
+ const handleFailure = useCallback(() => {
+ setSubmitStatus('FAILED');
+ if (onFailure) onFailure();
+ }, [onFailure]);
+
+ const handleSuccess = useCallback(() => {
+ setSubmitStatus('SUCCEEDED');
+ if (onSuccess) onSuccess();
+ }, [onSuccess]);
+
+ const handleSubmit = useCallback(async () => {
+ const submitResult = submitHandler ? await submitHandler(data) : undefined;
+
+ if (!submitResult) {
+ handleSuccess();
+ return;
+ }
+
+ setMessages(submitResult.messages);
+
+ const isSuccess = submitResult.validator(data);
+
+ setSubmitStatus(isSuccess ? 'SUCCEEDED' : 'FAILED');
+
+ if (isSuccess) handleSuccess();
+ else handleFailure();
+ }, [data, handleFailure, handleSuccess, submitHandler]);
+
+ const submit = useCallback(
+ async (e: FormEvent<HTMLFormElement>) => {
+ e.preventDefault();
+ setMessages(null);
+ setSubmitStatus('PENDING');
+
+ return handleSubmit();
+ },
+ [handleSubmit]
+ );
+
+ return { messages, submit, submitStatus };
+};
diff --git a/src/utils/hooks/use-form/use-form-values/index.ts b/src/utils/hooks/use-form/use-form-values/index.ts
new file mode 100644
index 0000000..664a862
--- /dev/null
+++ b/src/utils/hooks/use-form/use-form-values/index.ts
@@ -0,0 +1 @@
+export * from './use-form-values';
diff --git a/src/utils/hooks/use-form/use-form-values/use-form-values.test.ts b/src/utils/hooks/use-form/use-form-values/use-form-values.test.ts
new file mode 100644
index 0000000..f86d910
--- /dev/null
+++ b/src/utils/hooks/use-form/use-form-values/use-form-values.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from '@jest/globals';
+import { act, renderHook } from '@testing-library/react';
+import type { ChangeEvent } from 'react';
+import { useFormValues } from './use-form-values';
+
+/**
+ * Generate a new change event.
+ *
+ * @param {string} name - The field name.
+ * @param {unknown} value - The new value of the field.
+ * @returns {ChangeEvent<HTMLInputElement>} The event.
+ */
+const generateChangeEvent = (name: string, value: unknown, type = 'text') => {
+ const ev = new Event('change');
+ Object.defineProperty(ev, 'target', {
+ value: {
+ checked: type === 'checkbox' || type === 'radio' ? value : undefined,
+ name,
+ type,
+ value: type === 'checkbox' || type === 'radio' ? undefined : value,
+ },
+ writable: false,
+ });
+
+ return ev as unknown as ChangeEvent<HTMLInputElement>;
+};
+
+describe('useFormValues', () => {
+ const initialValues = {
+ foo: 'hello',
+ bar: false,
+ };
+ const newValues = {
+ foo: 'world',
+ bar: true,
+ };
+
+ it('can initialize the values', () => {
+ const { result } = renderHook(() => useFormValues(initialValues));
+
+ expect(result.current.values.bar).toBe(initialValues.bar);
+ expect(result.current.values.foo).toBe(initialValues.foo);
+ });
+
+ it('can update and reset the values', () => {
+ const { result } = renderHook(() => useFormValues(initialValues));
+
+ act(() => {
+ result.current.update(
+ generateChangeEvent('bar', newValues.bar, 'checkbox')
+ );
+ });
+
+ expect(result.current.values.bar).toBe(newValues.bar);
+
+ act(() => {
+ result.current.update(generateChangeEvent('foo', newValues.foo));
+ });
+
+ expect(result.current.values.foo).toBe(newValues.foo);
+
+ act(() => {
+ result.current.reset();
+ });
+
+ expect(result.current.values.bar).toBe(initialValues.bar);
+ expect(result.current.values.foo).toBe(initialValues.foo);
+ });
+});
diff --git a/src/utils/hooks/use-form/use-form-values/use-form-values.ts b/src/utils/hooks/use-form/use-form-values/use-form-values.ts
new file mode 100644
index 0000000..8a0962f
--- /dev/null
+++ b/src/utils/hooks/use-form/use-form-values/use-form-values.ts
@@ -0,0 +1,69 @@
+import {
+ type ChangeEventHandler,
+ useCallback,
+ useState,
+ type ChangeEvent,
+} from 'react';
+
+const isBooleanField = (
+ target: EventTarget & (HTMLInputElement | HTMLTextAreaElement)
+): target is EventTarget & HTMLInputElement =>
+ target.type === 'checkbox' || target.type === 'radio';
+
+export type UseFormValuesReturn<T extends Record<string, unknown>> = {
+ /**
+ * A method to reset the fields to their initial values.
+ *
+ * @returns {void}
+ */
+ reset: () => void;
+ /**
+ * A method to handle input or textarea update.
+ *
+ * @param {ChangeEvent<HTMLTextAreaElement | HTMLInputElement>} e - The event.
+ * @returns {void}
+ */
+ update: (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void;
+ /**
+ * The fields values.
+ */
+ values: T;
+};
+
+/**
+ * React hook to handle form values update and reset.
+ *
+ * @template {object} T - The object keys should match the fields name.
+ * @param {T} initialValues - The fields initial values.
+ * @returns {UseFormValuesReturn<T>} An object with values and two methods.
+ */
+export const useFormValues = <T extends Record<string, unknown>>(
+ initialValues: T
+): UseFormValuesReturn<T> => {
+ const [values, setValues] = useState(initialValues);
+
+ /**
+ * Reset the field to their initial values.
+ */
+ const reset = useCallback(() => {
+ setValues(initialValues);
+ }, [initialValues]);
+
+ /**
+ * Handle input and textarea update.
+ *
+ * @param {ChangeEvent<HTMLTextAreaElement | HTMLInputElement>} e - The event.
+ * @returns {void}
+ */
+ const update: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> =
+ useCallback(({ target }) => {
+ setValues((prevData) => {
+ return {
+ ...prevData,
+ [target.name]: isBooleanField(target) ? target.checked : target.value,
+ };
+ });
+ }, []);
+
+ return { values, reset, update };
+};
diff --git a/src/utils/hooks/use-form/use-form.test.ts b/src/utils/hooks/use-form/use-form.test.ts
new file mode 100644
index 0000000..e2c30b5
--- /dev/null
+++ b/src/utils/hooks/use-form/use-form.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, it } from '@jest/globals';
+import { act, renderHook } from '@testing-library/react';
+import type { ChangeEvent, FormEvent } from 'react';
+import { useForm } from './use-form';
+
+const generateSubmitEvent = () =>
+ new Event('submit', {
+ bubbles: true,
+ cancelable: true,
+ }) as unknown as FormEvent<HTMLFormElement>;
+
+/**
+ * Generate a new change event.
+ *
+ * @param {string} name - The field name.
+ * @param {unknown} value - The new value of the field.
+ * @returns {ChangeEvent<HTMLInputElement>} The event.
+ */
+const generateChangeEvent = (name: string, value: unknown, type = 'text') => {
+ const ev = new Event('change');
+ Object.defineProperty(ev, 'target', {
+ value: {
+ checked: type === 'checkbox' || type === 'radio' ? value : undefined,
+ name,
+ type,
+ value: type === 'checkbox' || type === 'radio' ? undefined : value,
+ },
+ writable: false,
+ });
+
+ return ev as unknown as ChangeEvent<HTMLInputElement>;
+};
+
+describe('useForm', () => {
+ it('can initialize the data', () => {
+ const initialValues = {
+ foo: 'impedit',
+ bar: 42,
+ };
+ const { result } = renderHook(() => useForm({ initialValues }));
+
+ expect(result.current.values.bar).toBe(initialValues.bar);
+ expect(result.current.values.foo).toBe(initialValues.foo);
+ });
+
+ it('can use a handler to validate the submit process', async () => {
+ const data = {
+ name: 'John',
+ };
+ const submitHandler = jest.fn((_data) => undefined);
+ const { result } = renderHook(() =>
+ useForm({
+ initialValues: data,
+ submitHandler,
+ })
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(3);
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(result.current.validationErrors).toBeNull();
+ expect(submitHandler).toHaveBeenCalled();
+ expect(result.current.submitStatus).toBe('SUCCEEDED');
+ });
+
+ it('can submit the data and reset to initial values', async () => {
+ const data = {
+ initial: {
+ name: 'John',
+ },
+ new: {
+ name: 'Phoebe',
+ },
+ };
+ const { result } = renderHook(() =>
+ useForm({
+ initialValues: data.initial,
+ })
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(3);
+
+ act(() => {
+ result.current.update(generateChangeEvent('name', data.new.name));
+ });
+
+ expect(result.current.values.name).toBe(data.new.name);
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(result.current.submitStatus).toBe('SUCCEEDED');
+ expect(result.current.values.name).toBe(data.initial.name);
+ });
+
+ it('can submit after validating the data', async () => {
+ const data = {
+ initial: {
+ name: 'John',
+ },
+ new: {
+ name: 'Phoebe',
+ },
+ errors: {
+ name: 'Expect name to have at least 2 letters.',
+ },
+ };
+ const submitHandler = jest.fn((_data) => undefined);
+ const { result } = renderHook(() =>
+ useForm({
+ initialValues: data.initial,
+ submitHandler,
+ validations: {
+ name: {
+ error: data.errors.name,
+ validator: (value) => value.length > 1,
+ },
+ },
+ })
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ act(() => {
+ result.current.update(generateChangeEvent('name', data.new.name));
+ });
+
+ expect(result.current.values.name).toBe(data.new.name);
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(result.current.validationErrors).toBeNull();
+ expect(submitHandler).toHaveBeenCalled();
+ expect(result.current.submitStatus).toBe('SUCCEEDED');
+ });
+
+ it('can abort submit if data validation fails', async () => {
+ const minAge = 18;
+ const data = {
+ initial: {
+ name: 'H',
+ age: 17,
+ },
+ errors: {
+ age: `Expect age to be at least ${minAge}.`,
+ name: 'Expect name to have at least 2 letters.',
+ },
+ };
+ const submitHandler = jest.fn((_data) => undefined);
+ const { result } = renderHook(() =>
+ useForm({
+ initialValues: data.initial,
+ submitHandler,
+ validations: {
+ age: {
+ error: data.errors.age,
+ validator: (value) => value >= minAge,
+ },
+ name: {
+ error: data.errors.name,
+ validator: (value) => value.length > 1,
+ },
+ },
+ })
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(result.current.validationErrors?.age).toBe(data.errors.age);
+ expect(result.current.validationErrors?.name).toBe(data.errors.name);
+ expect(submitHandler).not.toHaveBeenCalled();
+ expect(result.current.submitStatus).toBe('FAILED');
+ });
+
+ it('can partially validate the data before submit', async () => {
+ const data = {
+ initial: {
+ name: 'H',
+ age: 17,
+ },
+ errors: {
+ name: 'Expect name to have at least 2 letters.',
+ },
+ };
+ const submitHandler = jest.fn((_data) => undefined);
+ const { result } = renderHook(() =>
+ useForm({
+ initialValues: data.initial,
+ submitHandler,
+ validations: {
+ name: {
+ error: data.errors.name,
+ validator: (value) => value.length > 1,
+ },
+ },
+ })
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ await act(async () => {
+ await result.current.submit(generateSubmitEvent());
+ });
+
+ expect(result.current.validationErrors?.age).toBeUndefined();
+ expect(result.current.validationErrors?.name).toBe(data.errors.name);
+ expect(submitHandler).not.toHaveBeenCalled();
+ expect(result.current.submitStatus).toBe('FAILED');
+ });
+});
diff --git a/src/utils/hooks/use-form/use-form.ts b/src/utils/hooks/use-form/use-form.ts
new file mode 100644
index 0000000..492f820
--- /dev/null
+++ b/src/utils/hooks/use-form/use-form.ts
@@ -0,0 +1,134 @@
+import { useCallback, useState } from 'react';
+import type { DataValidator, Nullable } from '../../../types';
+import {
+ type FormSubmitHandler,
+ useFormSubmit,
+ type UseFormSubmitReturn,
+} from './use-form-submit';
+import { type UseFormValuesReturn, useFormValues } from './use-form-values';
+
+export type FormFieldValidation<T> = {
+ /**
+ * The error message.
+ */
+ error: string;
+ /**
+ * A function to validate the field value.
+ */
+ validator: DataValidator<T>;
+};
+
+export type FormValidations<T> = {
+ [K in keyof T]: FormFieldValidation<T[K]>;
+};
+
+export type FormValidationErrors<T> = {
+ [K in keyof T]?: string;
+};
+
+export type UseFormConfig<T> = {
+ /**
+ * The initial fields values.
+ */
+ initialValues: T;
+ /**
+ * A function to handle submit.
+ */
+ submitHandler?: FormSubmitHandler<T>;
+ /**
+ * An object with validator and error message to validate each field.
+ */
+ validations?: Partial<FormValidations<T>>;
+};
+
+export type UseFormReturn<T extends Record<string, unknown>> =
+ UseFormValuesReturn<T> &
+ UseFormSubmitReturn & {
+ /**
+ * The validation error for each field.
+ */
+ validationErrors: Nullable<FormValidationErrors<T>>;
+ };
+
+/**
+ * React hook to manage forms.
+ *
+ * @template {object} T - The object keys should match the fields name.
+ * @param {UseFormConfig<T>} config - The config.
+ * @returns {UseFormReturn<T>} The values, validations errors and form methods.
+ */
+export const useForm = <T extends Record<string, unknown>>({
+ initialValues,
+ submitHandler,
+ validations,
+}: UseFormConfig<T>): UseFormReturn<T> => {
+ const { values, reset, update } = useFormValues(initialValues);
+ const [validationErrors, setValidationErrors] =
+ useState<Nullable<FormValidationErrors<T>>>(null);
+
+ const areValuesValid = useCallback(async () => {
+ if (!validations) return true;
+
+ const keys = Object.keys(values) as Extract<keyof T, string>[];
+ const validationPromises = keys.map(async (key) => {
+ const value = values[key];
+ const field = validations[key];
+
+ if (!field) return true;
+
+ const isValidField = await field.validator(value);
+
+ if (!isValidField)
+ setValidationErrors((prevErr) => {
+ const newErrors: FormValidationErrors<T> = prevErr
+ ? { ...prevErr }
+ : {};
+
+ return {
+ ...newErrors,
+ [key]: field.error,
+ };
+ });
+
+ return isValidField;
+ });
+
+ const awaitedValidation = await Promise.all(validationPromises);
+
+ return awaitedValidation.every((isValid) => isValid);
+ }, [validations, values]);
+
+ const handleSubmit = useCallback(async () => {
+ setValidationErrors(null);
+
+ const isValid = await areValuesValid();
+
+ if (isValid && submitHandler) return submitHandler(values);
+
+ return {
+ validator: () => isValid,
+ messages: {
+ error: 'Has invalid values',
+ },
+ };
+ }, [areValuesValid, submitHandler, values]);
+
+ const handleSuccess = useCallback(() => {
+ reset();
+ }, [reset]);
+
+ const { messages, submit, submitStatus } = useFormSubmit(values, {
+ onSuccess: handleSuccess,
+ submit: handleSubmit,
+ });
+
+ return {
+ messages,
+ reset,
+ submit,
+ submitStatus,
+ update,
+ values,
+ validationErrors,
+ };
+};