DEV Community

Cover image for Dead simple React validations with Vest
Evyatar Alush
Evyatar Alush

Posted on • Edited on

Dead simple React validations with Vest

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(() => {

});
Enter fullscreen mode Exit fullscreen mode

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((data = {}) => {

});
Enter fullscreen mode Exit fullscreen mode

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)
});
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode
  • 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";
Enter fullscreen mode Exit fullscreen mode

and in your suite, add the following test:

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

validate.js is now supposed to look like this:

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

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

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 import our validation suite into our Form module:

// src/Form.js
import suite from './validate';
Enter fullscreen mode Exit fullscreen mode

And now, we need to make our form "aware" of our returned validation results, with suite.get(). Whenever we we run our validations, suite.get will contain the most up-to-date validation result.

// src/Form.js
// ...
export default function Form() {
  const [formState, setFormState] = useState({});

  const result = suite.get();
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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')}
      />
Enter fullscreen mode Exit fullscreen mode

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.

  const runValidate = (name, value) => {
    const res = validate({
      ...formState,
      ...{ [name]: value }
    });
  };
Enter fullscreen mode Exit fullscreen mode

Call runValidate from the handleChange handler:

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

Our component is supposed to begin like this:

export default function Form() {
  const [formState, setFormState] = useState({});

  const result = suite.get();

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

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

// ...
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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")}
      />
Enter fullscreen mode Exit fullscreen mode

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((data = {}, currentField) => {
  vest.only(currentField);
Enter fullscreen mode Exit fullscreen mode

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
  };
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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

  const cn = classNames(result, {
    invalid: "invalid",
    valid: "valid"
  });
Enter fullscreen mode Exit fullscreen mode

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")}
      />
Enter fullscreen mode Exit fullscreen mode

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()
  })
Enter fullscreen mode Exit fullscreen mode

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")}
      />
Enter fullscreen mode Exit fullscreen mode

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"
  });
Enter fullscreen mode Exit fullscreen mode

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]/);
  });
Enter fullscreen mode Exit fullscreen mode

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")}
      />
Enter fullscreen mode Exit fullscreen mode

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 extend enforce with a custom function.

In our example, let's extend enforce with isEmail from validator.js:

import vest, { test, enforce } from "vest";
import { isEmail } from 'validator';

enforce.extend({ isEmail })
Enter fullscreen mode Exit fullscreen mode

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

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

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")}
      />
Enter fullscreen mode Exit fullscreen mode

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);
    });
  }
Enter fullscreen mode Exit fullscreen mode

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

Handling the submit button

Our very last step is disabling 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>
Enter fullscreen mode Exit fullscreen mode

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

Top comments (4)

Collapse
 
roblevintennis profile image
Rob Levin

This is very interesting to use unit testing idioms…it does seem like it will make it easier to learn the surface API. I saw your pres at svelte conference too and I have vest on my queue to try with my AgnosticUI form components. Thanks for building this project and sharing!

Collapse
 
roblevintennis profile image
Rob Levin

Yeah, I had a chance to play with it over the weekend and hooked it up to my agnostic-svelte package and it worked great! I didn't use the classNames stuff and the validation only kicks in on touched but it's working quite nicely: See the demo.

It will definitely also work very nicely in React, Vue 3, and that's good news for AgnosticUI. Great stuff Evyatar — thanks for building this!

Collapse
 
mustafawm profile image
mcha

Very intriguing!

Thanks for the comprehensive post Evyatar.

Collapse
 
ealush profile image
Evyatar Alush

Thank you, I appreciate it.