React: Custom Validation Resolver with react-hook-form

Vin Jenks
7 min readApr 7, 2023

--

I figured this would be an interesting integration that at least a few devs out there have wondered about. While at Another, we went through a selection process on basic packages, to facilitate a shared validation schema design based on AJV.

NOTE: At that time, roughly two years ago, it seemed as if AJV integration w/ react-hook-form had been abandoned, so I went down the custom path, myself. As of RHF 2.9.0, AJV support as added to resolvers. I’ll demonstrate how I did it, regardless, since it can be used to integrate other validation libraries, or even your own custom validation logic. I’ll also demonstrate the built-in resolver that the RHF team created — they’re essentially identical, in the end.

…because JSON :)

AJV is a stellar JSON validation library, and is widely downloaded on npm. It’s also one of the oldest out there, and the best one I could find based on the JSON Schema spec. The idea being; raw JSON is more flexible to define validation schemas, than something programmatic like Joi or Yup. After all, JSON can be stored a number of ways and is more easily separated from code. It’s also the glue that holds our applications together!

If you’ve ever used react-hook-form, then you’re already aware of how fantastic this little form library is. Fast, efficient, and simple to use. It checks all the boxes and doesn’t get in your way, and prevents the need to re-create the wheel, as I’ve seen on many projects with React developers. The creators have also gone to great lengths to prevent component reloading, as much as possible.

With all that noise out of the way, away we go!

If you like, go ahead and clone the source of this article here.

I’ve setup a simple React project using create-react-app, with a single basic form:

form.js

Here’s the source of form.js. Pretty straightforward stuff, if you’ve worked with React and react-hook-form:

import React from "react";
import { useForm } from "react-hook-form";
import { validateResolver } from "./lib/validator";

import "./scss/form.scss";

const Form = () => {
const {
register,
handleSubmit,
formState: { errors }
} = useForm({
context: "contactForm",
resolver: validateResolver
});

const onSubmit = formData => {
console.log(`You said: ${JSON.stringify(formData, null, 4)}`)
};

return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>
Prefix
<select {...register("prefix")}>
<option value="">Select One</option>
<option value="Mr.">Mr.</option>
<option value="Ms.">Ms.</option>
<option value="Mrs.">Mrs.</option>
</select>
</label>
{errors.prefix && <span className="validation">{errors.prefix.message}</span>}
</div>
<div>
<label>
First Name
<input type="text" {...register("firstName")} />
</label>
{errors.firstName && <span className="validation">{errors.firstName.message}</span>}
</div>
<div>
<label>
Last Name
<input type="text" {...register("lastName")} />
</label>
{errors.lastName && <span className="validation">{errors.lastName.message}</span>}
</div>
<div>
<label>
Email
<input type="text" {...register("email")} />
</label>
{errors.email && <span className="validation">{errors.email.message}</span>}
</div>
<div>
<label>
Title
<input type="text" {...register("title")} />
</label>
{errors.title && <span className="validation">{errors.title.message}</span>}
</div>
<div>
<button type="submit">Submit</button>
</div>
</form>
</div>
);
};

export default Form;

The primary thing to note is; I’m referencing a function called validateResolver in the imports, and it’s utilized in the useForm hook, along with a context property. This is where all the magic happens.

The custom validator module, which contains validateResolver, looks like this:

import Ajv from "ajv";
import AjvFormats from "ajv-formats";
import AjvErrors from "ajv-errors";

//schemas
import { contactForm } from "../schema/contact";

//configure AJV
const ajv = new Ajv({
allErrors: true,
strict: false,
$data: true
});
AjvFormats(ajv);
AjvErrors(ajv);

/**
* Custom validation resolver - passed to react-hook-form
*/
const validateResolver = async (data, context) => {
//add schema by name
ajv.removeSchema(context);
ajv.addSchema(contactForm, context);

//pull by context prop in useForm()
let validate = ajv.getSchema(context);

// let { schema } = validate;

//run validation and pull errors
let isValid = await validate(data);
let { errors } = validate;

//hack/workaround for bogus, always-present "required" error
let errCount = errors.filter(err => err.instancePath !== "").length;

//let data through?
if (isValid || errCount === 0) {
return {
values: data,
errors: {}
};
}

//reduce errors to format form expects
let errorsFormatted = errors.reduce((prev, current) => ({
...prev,
[current.instancePath.replace("/", "")]: current
}), {});

//return expected format
return {
values: {},
errors: errorsFormatted
};
};

export {
validateResolver
};

I’m not going to delve into too many of the specifics here, since it’s a fairly simple integration which conforms to the react-hook-form custom validation documentation, found here.

The configuration options of the Ajv constructor can be found here. Basically, I’ve set it to allow custom error messages in the schemas, and a few other tweaks that I like to use.

Here’s the contactForm schema, referenced in the import:

export const contactForm = {
type: "object",
properties: {
prefix: {
type: "string",
minLength: 2,
maxLength: 4,
errorMessage: "Select a prefix",
},
firstName: {
type: "string",
minLength: 1,
maxLength: 50,
errorMessage: "Between 1 and 50 characters.",
},
lastName: {
type: "string",
minLength: 1,
maxLength: 50,
errorMessage: "Between 1 and 50 characters.",
},
title: {
type: "string",
maxLength: 50,
errorMessage: "Less than 50 characters.",
nullable: true
},
email: {
type: "string",
maxLength: 100,
pattern: "^\\w+([\\.-]?\\w+)*@\\w+([\\.-]?\\w+)*(\\.\\w{2,3})+$",
errorMessage: "Invalid email address.",
nullable: true
}
},
required: ["firstName, lastName", "email"],
additionalProperties: false
};

TL;DR — the validateResolver function adds the schema to Ajv, performs the validation, and either returns the original data passed into it, when valid, or it returns a specifically-formatted object, which contains the error messages that react-hook-form expects.

It’s probably easy to imagine, at this point, how you might use your own custom validation logic, or any other unsupported validation library — quite easily!

Once you’ve cloned the project, run npm install, and npm startto run it, you should see validation working, if you tap Submit without filling anything out:

…error messages correspond to those inside the JSON scema above!

It’s worth noting that if you set a minLength prop on a field, it’s essentially required, and the required prop is unnecessary. I included it to illustrate how to deal with a troublesome response, when you do use required, inside of validateResolver.

When you test this out, you’ll notice the focus and field highlighting are automatic. Pretty slick! Once you finally have a valid form, you should see the following output in the console:

As the title field is optional, you’re left with a blank string for that one. Since that has no minLength property and is not in the required array, you’ll need to test by adding more than 50 chars to the field, at which time you’ll see the validation error:

…back out a few characters, the message disappears!

That’s all there is to it! Of course, there are endless options for extending this to your needs, should you use a custom validator like this. For example, I wanted to be able to coerce values, which AJV doesn’t do, out of the box, since it is not part of the JSON Schema spec.

I won’t extend this post out any further with the details, but here’s a peek of how I handled the need to coerce a blank string (e.g. the Title field) to a null, which was more backend-friendly, when sending the data off to an API:

With this, a null value is found in the title field, if not filled in by the user.

AJV validation using the built-in resolver

I mentioned earlier in the post, that since my foray into integrating these two technologies, the RHF team has added a built-in integration. I’ve quickly scanned through the source code and they’re essentially doing what I had done, and the code looks very similar, in action.

import React from "react";
import { useForm } from "react-hook-form";

import { contactForm } from "./schema/contact";
import { ajvResolver } from "@hookform/resolvers/ajv";

import "./scss/form.scss";

const Form = () => {
const {
register,
handleSubmit,
formState: { errors }
} = useForm({
resolver: ajvResolver(contactForm)
});

const onSubmit = formData => {
console.log(`You said: ${JSON.stringify(formData, null, 4)}`)
};

return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>
Prefix
<select {...register("prefix")}>
<option value="">Select One</option>
<option value="Mr.">Mr.</option>
<option value="Ms.">Ms.</option>
<option value="Mrs.">Mrs.</option>
</select>
</label>
{errors.prefix && <span className="validation">{errors.prefix.message}</span>}
</div>
<div>
<label>
First Name
<input type="text" {...register("firstName")} />
</label>
{errors.firstName && <span className="validation">{errors.firstName.message}</span>}
</div>
<div>
<label>
Last Name
<input type="text" {...register("lastName")} />
</label>
{errors.lastName && <span className="validation">{errors.lastName.message}</span>}
</div>
<div>
<label>
Email
<input type="text" {...register("email")} />
</label>
{errors.email && <span className="validation">{errors.email.message}</span>}
</div>
<div>
<label>
Title
<input type="text" {...register("title")} />
</label>
{errors.title && <span className="validation">{errors.title.message}</span>}
</div>
<div>
<button type="submit">Submit</button>
</div>
</form>
</div>
);
};

export default Form;

One caveat: I could not get this to validate without the “bogus” error, caused by the presence of the required property, in the JSON validitation schema. Once removed, it behaved as expected. I found this strange, since it was used in the example on the @hookform/resolvers npm page.

That’s all for now! I hope this was useful and interesting for you!

--

--

Vin Jenks

Professional Geek, Musician, and Thinker. I write code for a living and love talking about it.