loading...
Cover image for Dead simple React validations with Vest

Dead simple React validations with Vest

ealush profile image Evyatar Alush Updated on ・11 min read

One of the things that get the most bad rep in web development is form validation. It is something that seems so trivial and a part of almost every app, yet almost always, as your feature gets more complex, your validations seem to grow in mess and complexity as well.

Alt Text

Vest is a fairly new framework that aims to fix that bad parts in form validations by getting inspired by unit testing framework such as Mocha and Jest. It is framework agnostic, which means that you can use it with any UI framework you currently use.

In this tutorial you will learn how to use the basics of Vest to easily validate forms written in React.

What we'll be making today?

We'll start with a basic registration form, already implemented in React.

In this tutorial, although not React heavy, I am assuming you are comfortable with React, and have some familiarity with React's useState hook.

The concepts you'll be learning here though, are NOT react specific, and can be applied to any framework.

At any point during this tutorial when you feel that you want to go off-script, feel free to head to the Vest documentation site to learn about other cool features.

In this form we will add the following validations:
✅ Username required
✅ Username is too short
✅ Password is too short
✅ TOS agreement -> Validation without an error
✅ Passwords do not match -> Cross field validation
✅ Only validating the field that's being changed by the user
✅ Email is invalid
✅ Password is weak -> Warning validation
✅ Disabling the submit button if there are any errors

Take a look at our finished product:

Try it live on CodeSandbox

Alt Text

Let's start

You can start by forking the starter sandbox. It contains a simple React form without any validations at all. All the form does at the moment is update the form state upon input change.

To make things simpler, it also contains an <Input/> component that's already designed to show our errors validation errors when we run them later on.

Alt Text

Begin with the basics

The main concept in Vest is that your validation sits separately from your feature. This allows you to easily refactor your feature without touching your validations.

So head over to the src/validate.js file. That's where we'll be spending most our time.

The first step is to import vest from 'vest';

Next we'll create a new vest suite with our form name and immediately export it.

import vest from 'vest';

export default vest.create('user_form', () => {

});

This export returns a function, that when we run it - it runs our validation.

Now, this function needs to somehow get our form data. We pass it over using our callback function, so let's add a data argument to our callback function.

import vest from "vest";

export default vest.create("user_form", (data = {}) => {

});

This is the initial structure that's true to every Vest suite.

The anatomy of a test

As noted before, Vest is inspired by the syntax of unit-testing frameworks, so we write tests in a similar fashion.

A structure of a jest test is roughly

test('message', () => {
    expect(value).toBe(anotherValue)
});

And the structure of a Vest test is (in this example, eqauls is one of many possible assertions).

test('field_name', 'message', () => {
    enforce(value).equals(anotherValue)
})
  • test - A function that runs a single validation test
  • 'field_name' - The field being validated
  • 'message' - The error that is presented to the user
  • enforce - Vest's assertion library (docs link)

Writing your first test

Our first test is "Username is required", we will check that it is not empty using .isNotEmpty().

First, add to your import statement the named imports of enforce and test.

import vest, { test, enforce } from "vest";

and in your suite, add the following test:

  test("username", "Username is required", () => {
    enforce(data.username).isNotEmpty();
  });

validate.js is now supposed to look like this:

import vest, { test, enforce } from "vest";

export default vest.create("user_form", (data = {}) => {
  test("username", "Username is required", () => {
    enforce(data.username).isNotEmpty();
  });
});

Connecting Vest to our form

Obviously, this should not work yet. We have done nothing to make our form work with Vest.

Head back to src/Form.js.

First, let's add the following imports

// src/Form.js
import vest from 'vest';
import validate from './validate';

Now, add a new React state to hold our validation result. We will initialize the state with vest.get("user_form") which returns the validation result of our suite

const [result, setResult] = useState(vest.get("user_form"));

To display the errors on the username field, find it and add it the errors property: errors={result.getErrors('username')}

      <Input
        name="username"
        label="Username"
        value={formState.username}
        onChange={handleChange}
        errors={result.getErrors('username')}
      />

Vest is now connected to your form, and whenever an error is present - your form will show it. But we did not yet make our form run our validations.

Create a new function called runValidate (the name doesn't matter) in the body of your React component:

This function takes the name of the changed field,
and its value. It then runs our validation agains the full state and sets the results in the result state.

  const runValidate = (name, value) => {
    const res = validate({
      ...formState,
      ...{ [name]: value }
    });
    setResult(res);
  };

Call runValidate from the handleChange handler:

  const handleChange = (name, value) => {
    setFormState({ ...formState, [name]: value });
    runValidate(name, value);
  };

Our component is supposed to begin like this:

export default function Form() {
  const [formState, setFormState] = useState({});
  const [result, setResult] = useState(vest.get("user_form"));

  const runValidate = (name, value) => {
    const res = validate({
      ...formState,
      ...{ [name]: value }
    });
    setResult(res);
  };

  const handleChange = (name, value) => {
    const nextFormState = { ...formState, [name]: value };
    setFormState(nextFormState);
    runValidate(name, value);
  };

// ...

Try typing something into the username field and then remove it. You're supposed to see our error message:
Alt Text

That was easy.

One cool thing about Vest's validation being test-driven, is that you can have multiple tests for the same field, meaning that you can easily display different errors depending on different criteria. For example, if you only want to allow usernames longer than 3 characters, simply add the following test after the first one in src/validate.js:

  test("username", "Username is too short", () => {
    enforce(data.username).longerThanOrEquals(3);
  });

Now, without any changes to our form our username field should be able to display both messages.

Alt Text

Adding a few more validation tests should be easy. Paste the following into your validation suite in src/validate.js. Here are some for our password and confirm_password inputs.

  test("password", "Password is required", () => {
    enforce(data.password).isNotEmpty();
  });

  test("password", "Password must be at least 6 chars long", () => {
    enforce(data.password).longerThanOrEquals(6);
  });

  test("confirm_password", "Passwords do not match", () => {
    enforce(data.confirm_password).equals(data.password);
  });

And in src/Form.js add the error property to the password and confirm_password inputs just as like we did with username:

      <Input
        name="password"
        label="Password"
        value={formState.password}
        onChange={handleChange}
        errors={result.getErrors("password")}
      />
      <Input
        name="confirm_password"
        label="Confirm Password"
        value={formState.confirm_password}
        onChange={handleChange}
        errors={result.getErrors("confirm_password")}
      />

If you've followed the last steps correctly, you should see the errors appearing for username, password and confirm password when making changes to the form.

Alt Text

The problem?
Because we pass the whole form data to vest, it validates all fields at once, even if the user did not interact with them yet. This is easily fixable.

Validating just the current field

Ideally, we want to only validate the field the user is currently interacting with, and the fields impacted by it. This is done with vest.only() (docs link).

To your suite's callback function add a second argument, call it currentField, it will hold the field we currently want to validate, and right below it, to your first line of your suite, add vest.only(currentField);.

export default vest.create("user_form", (data = {}, currentField) => {
  vest.only(currentField);

This alone won't do anything, because we did not yet tell Vest which field is our current field, so back in src/Form.js, modify runValidate so that it passes the name of the field as the second argument to validate:

  const runValidate = (name, value) => {
    const res = validate({
      ...formState,
      ...{ [name]: value }
    }, name); // -> field name got added here
    setResult(res);
  };

If you try to insert data to your inputs now, you'll see that fields are being validated only as they are interacted with.

Alt Text

Cool.

What about styles? 🎨

It's cool that we can validate, but we also want to make UI decisions based on the validation result, for example, painting error fields in red.

Once again, Vest has got you covered. It has a classNames (docs)utility function that you can use to generate a class name for your field's validation results.

As a first argument it takes your validation result, and as a second argument, a key value map of a validation state and its output class.


In src/Form.js add the following import:

import classNames from 'vest/classNames';

And in the body of our Form component, add the following assignment:

  const cn = classNames(result, {
    invalid: "invalid",
    valid: "valid"
  });

What we basically did here is this:

  1. We passed our validation result to classNames
  2. We told classnames that we would like invalid fields to get the invalid class, and valid fields to get the valid class.
  3. We stored the output in a const called cn for later access. Its value is a function.

The names you use are up to you, but in our tutorial we used these classes in our stylesheet file.

Now, you can go to our username, password and confirm_password inputs, and add a classNames property with a cn call with the respective field name as an argument.

      <Input
        name="username"
        label="Username"
        value={formState.username}
        onChange={handleChange}
        errors={result.getErrors("username")}
        className={cn("username")}
      />
      <Input
        name="password"
        label="Password"
        value={formState.password}
        onChange={handleChange}
        errors={result.getErrors("password")}
        className={cn("password")}
      />
      <Input
        name="confirm_password"
        label="Confirm Password"
        value={formState.confirm_password}
        onChange={handleChange}
        errors={result.getErrors("confirm_password")}
        className={cn("confirm_password")}
      />

Your inputs should now display the current validity state in color as well.

Colors

Validating Terms of Service checkbox

Now that we can display colors, it would be a good time to validate the terms of service checkbox and add a password strength warning.

In src/validate.js add:

  test('tos', () => {
    enforce(data.tos).isTruthy()
  })

And in src/Form.js add to our tos Checkbox component the appropriate className:

      <Checkbox
        name="tos"
        checked={formState.tos}
        onChange={handleChange}
        label="I have read and agreed to the terms of service."
        className={cn("tos")}
      />

Note that we did not add a failure message to our TOS field because it is already descriptive enough.

tos

Adding a password strength warning

Password strength is a sort of a weird creature in the world of validations. It shouts at you, but it still lets you submit the form.

Vest supports non-failing validations with the vest.warn() hook (docs link). It essentially tells Vest to store this field's result in a different basket so you can later treat it differently in your app.

To start, in src/Form.js add to our classNames call a warning class:

  const cn = classNames(result, {
    warning: "warning",
    invalid: "invalid",
    valid: "valid"
  });

And to our validation suite in src/validate.js add a simple password strength test.

  test("password", "Password is weak, Maybe add a number?", () => {
    vest.warn(); // using vest.warn marks this test as warning
    enforce(data.password).matches(/[0-9]/);
  });

If you test our field now, you'll notice that it appears correctly, but without our warning message.

Warning without a message

This is because we only added to our field the error messages for password. As we learned earlier - warnings are stored in a different "basket". We need to go back to src/Form.js and tell our password input to also take the warning messages:

      <Input
        name="password"
        label="Password"
        value={formState.password}
        onChange={handleChange}
        errors={[
          ...result.getErrors("password"),
          ...result.getWarnings("password")
        ]}
        className={cn("password")}
      />

Warning

Validating email

We saved email for this late stage because it is a tricky one. Of course, you can test an email Regular expression using enforce(data.email).matches(SomeEmailRegExp), and this is fine.

Another option is to load an extended version of enforce that has some more complex validations including isEmail. The drawback is that those validations are costly in term of bundle size - so it is completely up to you if you choose to use them in your own apps, or maintain your own set of regular expression.

Here's a list of all the extra rules supported by enforce-extended.

Just for the sake of this exercise, we will load the extended version of enforce.

So in src/validate.js remove the enforce import, and instead of it, load enforce from enforceExtended

import vest, { test } from "vest";
import enforce from 'vest/enforceExtended';

and now you can add to your suite the email test:

  test("email", "Email Address is not valid", () => {
    enforce(data.email).isEmail();
  });

And for the last time, in src/Form.js, add the error messages and classes to our email input:

      <Input
        name="email"
        label="Email"
        value={formState.email}
        onChange={handleChange}
        errors={result.getErrors("email")}
        className={cn("email")}
      />

Email

Fixing weird confirm behaviour

One thing you may notice when the password is empty and deleting whatever was inside confirm_password, it goes green - which is technically correct, the both password and confirm_password are empty. But this is kinda of wonky.

When password empty

This is fairly easy to fix, simply wrap the confirm_password test in a condition that it should not be validated unless password is present.

  if (data.password) {
    test("confirm_password", "Passwords do not match", () => {
      enforce(data.confirm_password).equals(data.password);
    });
  }

Now, whenever the password is empty, confirm_password will not be validated at all.

Handling the submit button

Our very last step is handling the submit button.

First, we want to make the submit button validate the whole form.

Simply call runValidate without any argument from handleSubmit. This will call our validation suite without specifying a currentField, so Vest will test the whole suite.

  const handleSubmit = e => {
    e.preventDefault();
    runValidate();
  };

And now, let's disable the submit button on error:

Let's add a disabled prop to our Button component, with the value of result.hasErrors().

result.hasErrors() Is a function that checks if we have any errors in our suite. If we also pass it a field name as an argument, it returns a boolean only for that field.

<Button disabled={result.hasErrors()}>Submit</Button>

Alt Text

Wrapping up.

That's it! I hope you have enjoyed this tutorial of Vest.
You can see the even with little effort you can write pretty complex form validations that's still easy to read and maintain.

You can carry on by yourself using Vest's documentation site, contribute to the on project on github, or hit me directly on twitter

Posted on by:

Discussion

pic
Editor guide
 

Very intriguing!

Thanks for the comprehensive post Evyatar.

 

Thank you, I appreciate it.