One of the most common features in an application is a form, whether it be for logging in, submitting a questionnaire, or entering contact information. When working with React, one of the best libraries for working with forms and custom form validation is react-hook-form (RHF). With forms, things can easily get out of hand when there are custom validation rules and error messages for each field. That’s where RHF’s validation capabilities come in handy. Out of the box, RHF offers the ability to add basic validation rules, like whether a field is required or what the minimum/maximum value can be. However, for more complex validation rules, RHF offers their “resolver” API, which allows user to add their own schema validation from libraries like Zod.
Zod for Form Validation
Zod is a library for creating schemas and validation rules using Typescript types. It offers advanced validation like refinement, transformation, and advanced error handling. Zod schemas can easily be plugged into a form using a resolver made by RHF called “zodResolver.” This effectively triggers the validation schemas when an event occurs (like when a value is changed or the form is submitted). Using Zod schemas for your RHF form also results in well-typed form data, as the schemas used in the resolver are also used to type the form.
For example, in this form, the field “firstName” is defined as a required field (using “required()”). This means that if no value is entered for “firstName”, an error for this field will be added to the form’s error context. Form errors can be accessed directly by checking the “errors” object for the field’s name, so in this case, “errors.firstName” would be defined as an object with a message that says “Required”.
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
firstName: z.string().required(),
lastName: z.string()
});
type FormData = z.infer;
const formMethods = useForm({
mode: "onChange",
resolver: zodResolver(formSchema),
});
const { formState: { errors } } = formMethods
Zod has many useful functions for validation, but the three most helpful ones for complex validation rules are “refine()”, “superRefine()”, and “transform()”. The functions for refining the input are most helpful in more complex situations like an input’s value needs to be in a specific format or a value needs to be evaluated in the context of other values in the form.
Some examples of when this could be useful are checking that:
– a phone number has the right number of digits in it
– the numeric amount of a currency is rounded to the correct decimal places
– all the address fields have been completed
Using refine() and superRefine()
In all of these situations, the basic validation functions do not easily cover the validation schema. So, it’s in these situations that adding a custom rule and error is helpful. Also note that the second parameter for adding an error message is optional. A default error message will be added to the context instead.
In the example below, you can check whether the phone number is valid for a U.S. country code in two ways. When “refine()” is used, the error is created for the whole phone number object when a full number is less than 7 characters long. However, if a form had three input fields for the phone number (for the country code, the area code, and the main number), we might want to show an error message on a more specific field. We can do this by using “superRefine()”, adding an error to an input using a specified “path.”
const phoneNumberField = z.object({
fullNumber: z.string(), // ex: 4567890
countryCode: z.string(), // ex: 1
areaCode: z.string(), // ex: 234
});
const phoneNumberWithRefine = phoneNumberField.refine((val) => {
return val.countryCode === "1" && val.fullNumber.length < 7; }, "Invalid U.S. phone number"); const phoneNumberWithSuperRefine = phoneNumberField.superRefine((val, ctx) => {
const invalidNumber = val.countryCode === "1" && val.fullNumber.length < 11;
if (invalidNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['fullNumber'],
message: "Invalid U.S. phone number",
});
}
});
Using transform()
Another useful function for elegant form validation is “transform()”. This function doesn’t check conditions and add errors to the form’s context. However, it can be used for sanitizing inputs or changing a value’s formatting. This can be a helpful step before calling “refine()” because it makes sure the input is in the expected shape before doing validation. Alternatively, it can be used to reformat an input into a display value, like when a set of inputted numbers needs to be formatted as money with decimal places.
In this example schema, the value is transformed to only have two decimal places and add digits after the decimal if necessary. Once the input is transformed into the expected shape, the “refine()” is used to ensure the input isn’t zero.
const paymentFieldSchema = z
.string()
.transform((value) => {
let [integerPart, decimal] = value.split(".");
integerPart = integerPart || "0";
decimal = decimal.slice(0, 1) || "00";
return `${integerPart}.${decimal}`;
})
.refine((val) => {
return val === "0.00";
}, "Invalid payment amount");
To conclude, using Zod with RHF not only streamlines validation logic but also enhances type safety, reusability, and overall maintainability. By leveraging functions like “refine()”, “superRefine()”, and “transform()”, forms can handle complex validation scenarios that go beyond the out-of-the-box capabilities of RHF. If you’re looking to scale up your form validation and error handling, integrating Zod with RHF is a flexible and elegant solution to use.