Lesson 8

Custom Validation Functions

The built-in rules like required, minLength, and pattern cover a lot of ground, but real forms need custom logic. Maybe a username can't contain spaces, a coupon code needs to be checked against an API, or a confirmation field must match another field. That's where the validate option comes in.

The validate Option

The register function accepts a validate property in its options object. You pass it a function that receives the current field value and returns either true (valid) or a string (the error message).

<input
  {...register("username", {
    validate: (value) => value.includes("@") || "Must contain @",
  })}
/>

That's the whole pattern. If the function returns true, validation passes. If it returns a string, that string becomes errors.username.message. There's no special error format or error codes to learn — just return what you want the user to see.

You can combine validate with the built-in rules too. They all run together:

<input
  {...register("username", {
    required: "Username is required",
    minLength: { value: 3, message: "At least 3 characters" },
    validate: (value) =>
      !value.includes(" ") || "Username cannot contain spaces",
  })}
/>

RHF evaluates the built-in rules first. If those pass, it runs your custom validate function.

Multiple Validators

A single validate function works for one-off checks, but sometimes a field has several custom rules. Instead of cramming them into one function, pass an object where each key is a validator name and each value is a validation function:

<input
  type="password"
  {...register("password", {
    validate: {
      hasUppercase: (v) => /[A-Z]/.test(v) || "Need an uppercase letter",
      hasNumber: (v) => /\d/.test(v) || "Need a number",
      minLength: (v) => v.length >= 8 || "Must be at least 8 characters",
    },
  })}
/>

Each validator runs independently. When one fails, its key becomes the error type in errors.password.type, and its returned string becomes errors.password.message. RHF reports the first failure it encounters, so the user sees one error at a time.

This object pattern keeps your rules readable. You can see at a glance what the field requires, and each rule has a clear name for debugging.

Async Validation

Sometimes you can't validate a field without talking to a server. Checking whether a username is already taken is the classic example. The validate function can return a Promise, and RHF will await it:

<input
  {...register("username", {
    validate: async (value) => {
      const response = await fetch(`/api/check-username?name=${value}`);
      const { available } = await response.json();
      return available || "Username is already taken";
    },
  })}
/>

RHF handles the async flow for you. While the validation is running, the form won't submit. If the user changes the value while a previous check is still in flight, RHF discards the stale result and only uses the response from the latest call.

One thing to watch out for: if your validation mode is onChange, this async function fires on every keystroke. That means a network request for every character typed. You have a couple of options to manage this. Setting mode: "onBlur" is the simplest — the check runs once when the user leaves the field. Alternatively, you can debounce inside the validate function itself, though that adds complexity. For most cases, onBlur is the right call for async validators.

Cross-Field Validation

What about rules that depend on more than one field? The classic example is "confirm your password." You need to compare the value of one field against another.

The useForm hook returns a getValues function that lets you read any field's current value. Use it inside a validate function to reference other fields:

function SignupForm() {
  const {
    register,
    handleSubmit,
    getValues,
    formState: { errors },
  } = useForm();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input
        type="password"
        {...register("password", { required: "Password is required" })}
      />

      <input
        type="password"
        {...register("confirmPassword", {
          validate: (value) =>
            value === getValues("password") || "Passwords don't match",
        })}
      />
      {errors.confirmPassword && (
        <p style={{ color: "red" }}>{errors.confirmPassword.message}</p>
      )}

      <button type="submit">Submit</button>
    </form>
  );
}

When confirmPassword is validated, the function calls getValues("password") to grab the current password value and compares. If they don't match, the user sees "Passwords don't match."

One thing to keep in mind: by default, changing the password field won't re-trigger validation on confirmPassword. The user might fix the password but the confirm field still shows an error. You can solve this with trigger("confirmPassword") inside a useEffect or by using mode: "onChange", but we'll cover those techniques when we look at reactive fields later in the course.

Complete Example

Let's put all of these patterns together in a signup form. This form has a username with an async availability check, a password with multiple custom rules, and a confirm password with cross-field validation:

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

type SignupData = {
  username: string;
  password: string;
  confirmPassword: string;
};

async function checkUsernameAvailable(username: string): Promise<boolean> {
  const response = await fetch(
    `/api/check-username?name=${encodeURIComponent(username)}`
  );
  const { available } = await response.json();
  return available;
}

function SignupForm() {
  const {
    register,
    handleSubmit,
    getValues,
    formState: { errors, isSubmitting },
  } = useForm<SignupData>({
    mode: "onBlur",
    defaultValues: {
      username: "",
      password: "",
      confirmPassword: "",
    },
  });

  const onSubmit = async (data: SignupData) => {
    await fetch("/api/signup", {
      method: "POST",
      body: JSON.stringify(data),
    });
    console.log("Account created!");
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="username">Username</label>
        <input
          id="username"
          {...register("username", {
            required: "Username is required",
            minLength: { value: 3, message: "At least 3 characters" },
            validate: async (value) => {
              const available = await checkUsernameAvailable(value);
              return available || "Username is already taken";
            },
          })}
        />
        {errors.username && (
          <p style={{ color: "red" }}>{errors.username.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          {...register("password", {
            required: "Password is required",
            validate: {
              hasUppercase: (v) =>
                /[A-Z]/.test(v) || "Must include an uppercase letter",
              hasNumber: (v) => /\d/.test(v) || "Must include a number",
              minLength: (v) =>
                v.length >= 8 || "Must be at least 8 characters",
            },
          })}
        />
        {errors.password && (
          <p style={{ color: "red" }}>{errors.password.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password</label>
        <input
          id="confirmPassword"
          type="password"
          {...register("confirmPassword", {
            required: "Please confirm your password",
            validate: (value) =>
              value === getValues("password") || "Passwords don't match",
          })}
        />
        {errors.confirmPassword && (
          <p style={{ color: "red" }}>{errors.confirmPassword.message}</p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating Account..." : "Sign Up"}
      </button>
    </form>
  );
}

export default SignupForm;

The form uses mode: "onBlur" so the async username check doesn't fire on every keystroke — it waits until the user tabs or clicks away. The password field has three separate validators that each report their own error message. The confirm password field reaches into getValues to compare against the password.

Between these patterns and the built-in rules from the previous lesson, you can validate almost anything by hand. But once your forms grow — especially with nested objects or arrays — writing individual validator functions gets tedious. In the next lesson, we'll define the entire validation schema with Zod and let React Hook Form run it automatically.