React hooks forms and data schema validation

React hooks forms and data schema validation

...a quick walkthrough on form common validations scenarios using yup

Hi, this is a quick overview of how to implement form validations, is not a secret, this is a common task, mostly any developer someday faces it. There are many validations vendors outside: Redux Forms, Formik, React Final Forms and React Hook Form .

We will talk about React Hook Form , I picked this because what I've seen, is the one that looks more natural for the React hooks ecosystem, source code is written in Typescript as well โœŒ๏ธ.

Another cool advantage is that you don't need special form components or marry a specific library or library to wire your validations, it is so minimalistic that only requires ref to the DOM input element or if the UI library of your preference exposes the ref, that's the way to go.

We will work together with Yup, which is an object schema validation, I have used before in a couple of projects, there is huge documentation, and will let you achieve even complex edge cases you can think of, like validations based on other fields, an array with compound object validations, and more.

Requirements

  • A bit familiar with React and Typescript

So what will cover in this blog post?

  • Setting up React Hook Form
  • Basics with Simple login form validation
  • Validate referencing other fields
  • Controlled and reusable fields
  • Async validations
  • Internalization of error messages
  • Conclusions

Setting up React Hook Form

We'll use CRA with Typescript https://create-react-app.dev/docs/adding-typescript/, because is so easy to start coding, but I will keep track of the samples here so you can check the finished code codesandbox.io/s/react-and-form-validations..

Given this, we need to install react-hook-form, because is the main dependency but as I mentioned before, we are going to use object schema validation, that's why we also need yup github.com/jquense/yup and react hook form resolvers @hookform/resolvers

Basics with Simple Login Form Validation

As simple as it is a login form validation, we can make our code quite robust mixing types and yup schemas.

import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";

type LoginAttributes = {
  username: string;
  password: string;
};

const schema: yup.SchemaOf<LoginAttributes> = yup.object().shape({
  username: yup.string().email().required(),
  password: yup.string().required()
});

export const Login = () => {
  const { register, handleSubmit, errors } = useForm({
    resolver: yupResolver(schema)
  });

  const onSubmit = (data: LoginAttributes) => alert(JSON.stringify(data));

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="text" name="username" ref={register} />
      <p>{errors.username?.message}</p>

      <input type="password" name="password" ref={register} />
      <p>{errors.password?.message}</p>

      <input type="submit" />
    </form>
  );
};

On this sample, we have LoginAttributes that way we map the yup schema to a regular type using yup.SchemaOf but also we can use it to type the onSubmit parameter, easy... . On the native onSubmit is called the handleSubmit provided by useForm.

To register each input to the validation we use React ref, if you are playing with React Material or ChakraUI, you can check its documentation on how to access the ref of the input DOM elements.

The error messages live on the errors map, it contains errors for all the fields accessible by the key we defined.

By default React Hook Form shows up validation on submit, if we want to change this behavior on useForm configuration there is the mode attribute, like to onTouched so when field is touched and blurred it shows the error message.

const { register, handleSubmit, errors } = useForm({
  mode: "onTouched",
  resolver: yupResolver(schema)
});

Reference other fields

One common case is the user registration with password confirmation, Yup provides a simple way to access other fields:

type RegistrationAttributes = {
  username: string;
  password: string;
  passwordConfirmation: string;
};

const schema: yup.SchemaOf<RegistrationAttributes> = yup.object().shape({
  username: yup.string().email().required(),
  password: yup.string().required(),
  passwordConfirmation: yup
    .string()
    .oneOf([yup.ref("password"), null, ""], "Passwords must match")
    .required()
});

The oneOf validation method restricts input value to be one of the values in the list only, but you can see also yup.ref("password"), so we are telling that passwordConfirmation has to be the value of password field. However we need to add null and "" to make the required() validation also work. Finally this validation only passes if input value is exactly as password.

Note: This is also possible with .test( ... ) method, but this approach is more verbose and maybe useful in other more complex scenarios.

Async validation

Sometimes we have to validate against a service, for example, a credit card or bank number, this type of actions are not instant so it has to wait sometime in order to receive a response from the service, here is where async validations come to rescue and Yup supports really well:

Account attributes

type BankAccountAttributes = {
  accountNumber: string;
};

Yup provides a .test(...) method to let us validate asynchronously the field, it receives parameters: validation name, message, and async callback, we are returning a Promise here because validateBankFunction is debounced :

const schema: yup.SchemaOf<BankAccountAttributes> = yup.object().shape({
  accountNumber: yup
    .string()
    .required("Your bank account is required")
    .test(
      "valid-bank-account",
      "Your bank account is not active",
      async (value, testContext) =>
        // debounced function does not return Promise, can't use async/await
        new Promise((resolve) =>
          validateBankAccount(value, testContext, resolve)
        )
    )
});

As you can see we are using lodash to debounce the function

const validateBankAccount = debounce(
  (
    value: string,
    testContext: yup.TestContext,
    resolve: (val: boolean) => void
  ) => {
    // simulate HTTP request
    setTimeout(() => {
      if (value === "000000") {
        return resolve(false);
      }
      return resolve(true);
    }, 600);
  },
  800
);

From useForm also is possible to use the form state isValidating to disable submit button if you need to:

const {
    register,
    handleSubmit,
    errors,
    formState: { isValidating, isValid }
} = useForm({
    mode: "all",
    resolver: yupResolver(schema)
});

Sample: Bank number validation

Controlled and reusable fields

Often we faced scenarios reusing same fields or group of fields, one use case, the address, it does not matter it is your home address or billing address, even though those 2 addresses are different they have the same inputs, let's see how to achieve this, first would be good to install error component, helps a lot on these nested scenarios, npm install @hookform/error-message, we still need a base type and the form validations:

type AddressAttributes = {
  country: string;
  billingAddress: {
    zipCode: string;
    street: string;
  };
};

const schema: yup.SchemaOf<AddressAttributes> = yup.object().shape({
  country: yup.string().required(),
  billingAddress: yup.object().shape({
    street: yup.string().required(),
    zipCode: yup
      .string()
      .matches(/^[0-9]+$/, "Invalid zip code")
      .required()
  })
});

but we will use the controller capabilities that lets you isolate the field, we need the control and name of this field as props passed from the parent component and initialize the useController hook for each of the fields belonging to address:

const AddressControl = ({ control, name }: UseControllerOptions) => {
  const zipCode = useController({
    name: `${name}.zipCode`,
    control,
    defaultValue: ""
  });
  const street = useController({
    name: `${name}.street`,
    control,
    defaultValue: ""
  });
  return (
    <>
      <div>
        <input type="text" {...zipCode.field} />
        <ErrorMessage
          name={`${name}.zipCode`}
          render={({ message }) => <p>{message}</p>}
        />
      </div>
      <div>
        <input type="text" {...street.field} />
        <ErrorMessage
          name={`${name}.street`}
          render={({ message }) => <p>{message}</p>}
        />
      </div>
    </>
  );
};

finally we are able to have 2 times the same component from the parent but using different names:

 return (
    <FormProvider {...formMethods}>
      <form onSubmit={handleSubmit(onSubmit)}>
        <select name="country" ref={register}>
          <option value="">Select a country</option>
          <option value="CR">CR</option>
          <option value="US">US</option>
        </select>
        <p>{errors.country?.message}</p>
        <h3>home address</h3>
        <AddressControl control={control} name="homeAddress" />
        <h3>billing address</h3>
        <AddressControl control={control} name="billingAddress" />

        <input type="submit" />
      </form>
    </FormProvider>

if you noticed, also we used FormProvider initialized with formsMethods which is the object returned by the useForm, ok the FormProvider makes it possible to use the ErrorMessage and obtain the errors triggered from the field component and also if you need to use the useFormContext (no in this case), code source here.

Internalization of error messages

Yup comes with bundled default messages for its validation functions, however, we can pass a custom message through the function or even use its customization strategy for localization rewriting the locale dictionary. Personally I prefer the former approach, passing a custom key into the validation functions, for example if we have this:

const schema: yup.SchemaOf<LoginAttributes> = yup.object().shape({
  username: yup.string().email().required(),
  password: yup.string().required()
});

We can pass the translation keys you set in your app localization mechanism:

const schema: yup.SchemaOf<LoginAttributes> = yup.object().shape({
  username: yup.string().email('form.errors.email').required('form.errors.required'),
  password: yup.string().required('form.errors.required')
});

Then if you are using a library like react 18next, can call the translation function from the component:

<input type="text" name="username" ref={register} />
<p>{t(errors.username?.message)}</p>

Additionally ${path} can be used in the error message to get the field name , can be used like 'form.errors.${path}'

Conclusions

This is just a summary of common cases, but react hook form and yup goes beyond, with a lot of more validation mechanisms.

Other samples in my sandbox

  • useFormContext helps to build deeply nested forms into separate components.
  • Working with fields lists

For more examples, I created for this post visit this code sandbox . If you enjoyed this post please like ๐Ÿ‘ and share ๐Ÿš€ !!