Introduction
Forms are a common feature in most applications, and they typically involve maintaining a state, either locally or globally, that reflects any changes made to the form.
Today, I will show you how to create a custom hook to manage the state of forms. Instead of relying on libraries created by the community, we will use Superstruct to validate JSON objects.
Assumed knowledge
The following would be helpful to have:
- Basic knowledge of React
- Basic knowledge of Form Handling
Getting Started
Project Setup
Run the following command in a terminal:
yarn create vite app-form --template react-ts
cd app-form
Now we can install the necessary dependencies:
yarn install superstruct
Structure Definition
I suggest starting by creating a file for the useForm
hook. Next, it would be beneficial to define some data types to standardize the hook's implementation.
The initial structure we will define is the hook's arguments, which will include three properties:
-
initialValues
- corresponding to the initial state of the form, represented as an object with keys as the names of inputs. -
validationSchema
- this is the schema that will be used to validate the form state. -
resetOnSubmit
- if the form is submitted, the values of each key/input would be reset
Which would be similar to the following:
// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";
interface IArgs<T> {
initialValues: T;
validationSchema: Struct<T, unknown>;
resetOnSubmit?: boolean;
}
// ...
The next data type to be defined is errors, we will use the generic feature to infer each of the properties from the initial state, as follows:
// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";
interface IArgs<T> {
initialValues: T;
validationSchema: Struct<T, unknown>;
resetOnSubmit?: boolean;
}
type IErrors<T> = Record<keyof T, string> | undefined;
// ...
We will use a higher-order function to invoke the submission, the function that will be invoked has the following data type:
// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";
interface IArgs<T> {
initialValues: T;
validationSchema: Struct<T, unknown>;
resetOnSubmit?: boolean;
}
type IErrors<T> = Record<keyof T, string> | undefined;
type IHandleSubmit<T> = (values: T) => void;
// ...
The next structure to define are the props that we want to assign to inputs through the hook, this way we can handle some properties/attributes, as follows:
// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";
interface IArgs<T> {
initialValues: T;
validationSchema: Struct<T, unknown>;
resetOnSubmit?: boolean;
}
type IErrors<T> = Record<keyof T, string> | undefined;
type IHandleSubmit<T> = (values: T) => void;
interface IGetInputOptions {
includeError?: boolean;
type?: "text" | "checkbox" | "textarea";
placeholder?: string;
}
// ...
Next, the function responsible for registering the input in the hook must return the input value, the onChange function, and the input type for it to be controlled.
// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";
interface IArgs<T> {
initialValues: T;
validationSchema: Struct<T, unknown>;
resetOnSubmit?: boolean;
}
type IErrors<T> = Record<keyof T, string> | undefined;
type IHandleSubmit<T> = (values: T) => void;
interface IGetInputOptions {
includeError?: boolean;
type?: "text" | "checkbox" | "textarea";
placeholder?: string;
}
interface IGetInputProps<T> extends Omit<IGetInputOptions, "includeError"> {
onChange: (evt: ChangeEvent<unknown>) => void;
error?: string;
checked?: boolean;
value?: number | string;
name: keyof T;
}
// ...
Hook Creation
After defining the data types, the next step is to create the functions for the hook. First, two states must be defined, one for managing the form state and another for managing form validation errors. Next, a function to reset the hook, including the previously defined states, must be created.
// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";
// ...
export const useForm = <T extends Record<string, any>>({
initialValues,
validationSchema,
resetOnSubmit = true,
}: IArgs<T>) => {
const [values, _setValues] = useState<T>(initialValues);
const [errors, _setErrors] = useState<IErrors<T>>(undefined);
const reset = useCallback(() => {
_setErrors(undefined);
_setValues(initialValues);
}, []);
// ...
};
We will now create an internal function named _onChange
to handle the form's input, which takes the target/input instance into account. By using the Input instance (Input, Textarea, etc), the values will be handled differently.
// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";
// ...
export const useForm = <T extends Record<string, any>>({
initialValues,
validationSchema,
resetOnSubmit = true,
}: IArgs<T>) => {
const [values, _setValues] = useState<T>(initialValues);
const [errors, _setErrors] = useState<IErrors<T>>(undefined);
const reset = useCallback(() => {
_setErrors(undefined);
_setValues(initialValues);
}, []);
const _onChange = useCallback(({ target }: ChangeEvent<unknown>) => {
let finalValue: string | boolean;
let key: string;
if (target instanceof HTMLInputElement) {
const { type, checked, value, name } = target;
finalValue = type === "checkbox" ? checked : value;
key = name;
} else if (target instanceof HTMLTextAreaElement) {
const { value, name } = target;
finalValue = value;
key = name;
}
_setValues((currentValues) => ({ ...currentValues, [key]: finalValue }));
}, []);
// ...
};
The following step is to create a function that considers the form's state and the validation schema passed as arguments. If an error occurs and it is an instance of a Superstruct error, the error object should be flattened.
// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";
// ...
export const useForm = <T extends Record<string, any>>({
initialValues,
validationSchema,
resetOnSubmit = true,
}: IArgs<T>) => {
const [values, _setValues] = useState<T>(initialValues);
const [errors, _setErrors] = useState<IErrors<T>>(undefined);
const reset = useCallback(() => {
_setErrors(undefined);
_setValues(initialValues);
}, []);
const _onChange = useCallback(({ target }: ChangeEvent<unknown>) => {
let finalValue: string | boolean;
let key: string;
if (target instanceof HTMLInputElement) {
const { type, checked, value, name } = target;
finalValue = type === "checkbox" ? checked : value;
key = name;
} else if (target instanceof HTMLTextAreaElement) {
const { value, name } = target;
finalValue = value;
key = name;
}
_setValues((currentValues) => ({ ...currentValues, [key]: finalValue }));
}, []);
const _validate = useCallback(() => {
try {
assert(values, validationSchema);
} catch (error) {
if (error instanceof StructError) {
const errorObj = error.failures().reduce(
(acc, { key, message }) => ({
...acc,
[key]: message,
}),
{}
);
_setErrors(errorObj as IErrors<T>);
return errorObj;
}
}
return {};
}, [values, validationSchema]);
// ...
};
Then, we can proceed to create the function that will register an input in the hook, called getInputProps
. This function will be responsible for assigning the input value, the internal _onChange
function, and other properties related to the input.
// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";
// ...
export const useForm = <T extends Record<string, any>>({
initialValues,
validationSchema,
resetOnSubmit = true,
}: IArgs<T>) => {
const [values, _setValues] = useState<T>(initialValues);
const [errors, _setErrors] = useState<IErrors<T>>(undefined);
const reset = useCallback(() => {
_setErrors(undefined);
_setValues(initialValues);
}, []);
const _onChange = useCallback(({ target }: ChangeEvent<unknown>) => {
let finalValue: string | boolean;
let key: string;
if (target instanceof HTMLInputElement) {
const { type, checked, value, name } = target;
finalValue = type === "checkbox" ? checked : value;
key = name;
} else if (target instanceof HTMLTextAreaElement) {
const { value, name } = target;
finalValue = value;
key = name;
}
_setValues((currentValues) => ({ ...currentValues, [key]: finalValue }));
}, []);
const _validate = useCallback(() => {
try {
assert(values, validationSchema);
} catch (error) {
if (error instanceof StructError) {
const errorObj = error.failures().reduce(
(acc, { key, message }) => ({
...acc,
[key]: message,
}),
{}
);
_setErrors(errorObj as IErrors<T>);
return errorObj;
}
}
return {};
}, [values, validationSchema]);
const getInputProps = useCallback(
(
name: keyof T,
{ includeError, type = "text", placeholder }: IGetInputOptions = {}
) => {
const props: IGetInputProps<T> = { onChange: _onChange, name };
if (includeError) props.error = errors?.[name];
if (type === "checkbox") {
props.checked = values?.[name];
props.type = "checkbox";
} else {
props.value = values?.[name];
props.type = type ?? "text";
if (placeholder) props.placeholder = placeholder;
}
return props;
},
[errors, values]
);
// ...
};
Finally, we will create a function to handle the form submission. It must prevent the default behavior, validate the form state, and if successful, reset it.
// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";
// ...
export const useForm = <T extends Record<string, any>>({
initialValues,
validationSchema,
resetOnSubmit = true,
}: IArgs<T>) => {
const [values, _setValues] = useState<T>(initialValues);
const [errors, _setErrors] = useState<IErrors<T>>(undefined);
const reset = useCallback(() => {
_setErrors(undefined);
_setValues(initialValues);
}, []);
const _onChange = useCallback(({ target }: ChangeEvent<unknown>) => {
let finalValue: string | boolean;
let key: string;
if (target instanceof HTMLInputElement) {
const { type, checked, value, name } = target;
finalValue = type === "checkbox" ? checked : value;
key = name;
} else if (target instanceof HTMLTextAreaElement) {
const { value, name } = target;
finalValue = value;
key = name;
}
_setValues((currentValues) => ({ ...currentValues, [key]: finalValue }));
}, []);
const _validate = useCallback(() => {
try {
assert(values, validationSchema);
} catch (error) {
if (error instanceof StructError) {
const errorObj = error.failures().reduce(
(acc, { key, message }) => ({
...acc,
[key]: message,
}),
{}
);
_setErrors(errorObj as IErrors<T>);
return errorObj;
}
}
return {};
}, [values, validationSchema]);
const getInputProps = useCallback(
(
name: keyof T,
{ includeError, type = "text", placeholder }: IGetInputOptions = {}
) => {
const props: IGetInputProps<T> = { onChange: _onChange, name };
if (includeError) props.error = errors?.[name];
if (type === "checkbox") {
props.checked = values?.[name];
props.type = "checkbox";
} else {
props.value = values?.[name];
props.type = type ?? "text";
if (placeholder) props.placeholder = placeholder;
}
return props;
},
[errors, values]
);
const submitForm = useCallback(
(handleSubmit: IHandleSubmit<T>) => (evt: FormEvent) => {
evt.preventDefault();
const validationErrors = _validate();
if (Object.keys(validationErrors).length === 0) {
handleSubmit(values);
if (resetOnSubmit) reset();
}
},
[values, resetOnSubmit]
);
return {
values,
errors,
getInputProps,
submitForm,
reset,
};
};
How to use
The hook's core functionality has been defined, and we now have everything necessary to handle forms. Next, let's create a small example of how to use it.
Ideally, we would create a validation schema and use it to infer a data type for use as a generic, providing autocomplete. Lastly, we would connect the hook, form, and corresponding inputs.
// @/src/App.tsx
import { useCallback } from "react";
import { object, nonempty, string, boolean, Infer } from "superstruct";
import { useForm } from "./hooks/useForm";
const validationSchema = object({
name: nonempty(string()),
message: nonempty(string()),
isChecked: boolean(),
});
type IFormValues = Infer<typeof validationSchema>;
const App = () => {
const { values, errors, getInputProps, submitForm } = useForm<IFormValues>({
initialValues: {
name: "",
message: "",
isChecked: false,
},
validationSchema,
});
const onSubmit = useCallback((formValues: IFormValues) => {
alert(JSON.stringify(formValues));
}, []);
return (
<section>
<form onSubmit={submitForm(onSubmit)}>
<input
{...getInputProps("name", { placeholder: "Type your name..." })}
/>
{errors?.name && <small>{errors.name}</small>}
<textarea
{...getInputProps("message", {
type: "textarea",
placeholder: "Leave a message...",
})}
/>
{errors?.message && (
<>
<small>{errors.message}</small>
<br />
</>
)}
<input {...getInputProps("isChecked", { type: "checkbox" })} />
<br />
<button type="submit">Send</button>
</form>
<pre>
<code>{JSON.stringify(values, null, 2)}</code>
</pre>
</section>
);
};
export default App;
Conclusion
I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.
Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.
Top comments (0)