HTML5 Aided Vue.js form validation

3 May, 2019

There are many great tools out there for form validation, being a creator of one I was wondering if we can get away with something simple.

Form validation is a complex topic to nail down, but it depends on the size of your project and if there are complex validations required. I wouldn't recommend using a library for sole purpose of checking if the user filled required fields or not.

In that case we can get away with something like this:

// You can just do this.
if (this.name === '') {
  this.errors.push('Name is required.');
}

But, this is not re-usable and you would need to put the validation logic everywhere in your components. To me, client-side validation is a UX and a DX tool. It allows the user to correct their mistakes without waiting for the server to validate, and for developers it should get out of their way.

Aside from complexity, there is the matter of "style". Form validation in Vue.js has two schools of thought, the declarative and the imperative. It boils down to whether your validation is declared in the template or the JavaScript. I'm a fan of the first approach because it is more natural, borrowing from the HTML5 validation style makes it very easy to use and follow.

The Vue.js Cookbook has a good article on form validation, it walks you through the concepts of validation process in your components. Again this is not very reusable, lets define our goals here.

Our validation tool should be able to:

  • Specify which inputs need to be validated.
  • Show error messages for said inputs.

So we need a way to define validation for our inputs, and get an appropriate error message to show to the user. I only have good news about this, we already have what we need built in right in the browser.

An often forgotten about concept in HTML5 is the native validation API. Let us review what need from this API.

We need to mark an input as validatable, and put our constraints on it. The native API allows us to do so, for example this is a required input:

<input type="text" required>

In our JS we can check if the field is valid or not by calling el.checkValidity() which returns a boolean indicating the valid state of the input. So in basic terms we can do the following.

<input type="text" required ref="name">

Then in our js:

// somewhere in a method
let isValid = this.$refs.name.checkValidity();
if (!isValid) {
  this.errors.push('Nah');
}

This is great, we no longer need to write the validation logic. But we need to make it clear why the input failed validation. Our next step is to be able to show error messages related to those errors.

Aside from checkValidity we have other useful properties we can use. For example the validity object or validationMessage which we will use for our next goal.

First we need to interface our input somehow to our Vue instance. We want our interface to be re-usable, so we don't keep repeating the logic in every component. The refs are great but it can get pretty annoying in large forms. Our interface should have an access to both the Vue component and the input element. Vue.js Directives shine in those situations.

Our directive should be able to pickup the element reference, and associate a reactive property on the Vue instance with its error message. Let's call our directive validate.

Vue.directive('validate', {
  bind (el, _, vnode) {
    // We don't care about binding here.
    el.addEventListener('input', e => {
      const vm = vnode.context; // this is the Vue instance.

      // We use Object.assign to make sure everything is reactive.
      // And because we used an object for our error storage, since a key-value data structure
      // is better than a lookup array in our case.
      vm.errors = Object.assign({}, vm.errors, {
        [el.name]: e.target.validationMessage
      });
    });
  }
});

Then in our template we can do this:

<input type="text" name="name" v-validate required>
<span>{{ errors.name }}</span>

Don't forget to add errors property on your Vue instance:

export default {
  data: () => ({
    errors: {}
  })
}

You can see it in action here:

What is great about this is that all the constrains will work without effort. I added an email field to the form and it worked and it even provides different messages depending on the error. We wrapped a native API to be re-usable within our app.

There are a few things to iron out to make sure everything is neat and tidy. First, we need to remove the need for embedding an errors object each time validation is used. We can wrap our directive and data in a mixin like this:

// mixins.js
export const ValidateMixin = {
  data: () => ({
    errors: {}
  }),
  directives: {
    validate: {
      bind (el, _, vnode) {
        el.addEventListener('input', e => {
          const vm = vnode.context;
          vm.errors = Object.assign({}, vm.errors, {
            [el.name]: e.target.validationMessage
          });
        });
      }
    }
  }
};

Great, now we can easily use that mixin in our components like this:

import { ValidateMixin } from '@/mixins';

// My Component or page with a form.
export default {
  mixins: [ValidateMixin]
};

I didn't register the mixin globally, because we don't need validation everywhere in our app. We want to reduce the mental overhead of what got injected globally.

The last remaining thing to make our little mixin great is to be able to trigger validations. For example we may need to validate before submitting a form. We can add a validate method to our mixin, but how would it trigger the validation in our directive?

There are endless ways we can do this with, let's use events to achieve that:

function updateMessage(el, vm) {
  vm.errors = Object.assign({}, vm.errors, {
    [el.name]: el.validationMessage
  });
}

export const ValidateMixin = {
  data: () => ({
    errors: {}
  }),
  computed: {
    hasErrors() {
      // Check if we have errors.
      return Object.keys(this.errors).some(key => {
        return !!this.errors[key];
      });
    }
  },
  directives: {
    validate: {
      bind(el, _, vnode) {
        const vm = vnode.context;
        el.addEventListener("input", e => {
          updateMessage(e.target, vm);
        });
        vnode.context.$on("validate", () => {
          updateMessage(el, vm);
        });
      }
    }
  },
  methods: {
    validate() {
      this.$emit("validate");
    }
  }
};

I have added a computed hasErrors property which comes in handy when you want to know if the form has any errors at any given moment.

Your component form will look like this:

import { ValidateMixin } from "@/mixins";

export default {
  name: "Form",
  mixins: [ValidateMixin],
  methods: {
    onSubmit() {
      this.validate();
      if (this.hasErrors) {
        console.log("Hold it right there!");
        return;
      }

      console.log("Sending to server...");
    }
  }
};

And that's it, you have a very small but powerful validation tool at your hands. You can use the goodness of HTML5 validation and the accessibility perks that comes with it. You can see it in action here:

As an added bonus, messages will appear in the browser's display language:

Arabic error messages showing in response to invalid form inputs

To wrap things up, form validation is a complex subject, we can get away with little to no code. But depending on your app complexity, things will start to tear down pretty fast. Here is a few things you may want to watch out for:

  • Asynchronous validation.
  • Validating custom components.
  • Complex and custom validation rules.

That is why using a validation library is more than a convenience, it saves you a lot of time. You can invest that time in working on your business logic instead of worrying about form validation.