Simplifying Form Handling in Vue Applications By Leveraging HTML

I saw another article about form validation in Vue and it inspired me to write this. It wasn't a bad article, but the solution was over-engineered, so I wanted to demonstrate how you can achieve the same thing by leveraging HTML.

Ok, form validation starts with a <form>, so let's make a form:

<form>
  <input type="text" id="name">
  <input type="email" id="email">
  <input type="password" id="password">
  <input type="password" id="password_confirm">
  <button type="submit">Submit</button>
</form>

Easy enough. Let's see what else we can do with HTML...

<form novalidate>
  <input type="text" id="name" required minlength="3">
  <input type="email" id="email" required>
  <input type="password" id="password" required>
  <input type="password" id="password_confirm" required>
  <button type="submit">Submit</button>
</form>

Nice! These attributes are part of the Constraint Validation spec, which among other things, includes seven validation attributes you can add to form controls to automatically apply validation (not what we want though) and to expose validation properties on those elements (that's what we're after). Let's look at the two attributes we're using:

  • required means there must be a value

  • minlength="3" means the value must be a minimum of three characters

In addition to those, the email input type comes with intrinsic validation, which constrains the value to an email-like string.

With that HTML alone, the browser would automatically provide a validation UX to the user, which is nice, but in our case we want to control the UX. So, to retain the underlying validation goodness those attributes get us, but avoid the browser UX, we must add the novalidate attribute to the form. This approach is called static validation and that's where JavaScript comes in:

<form onsubmit="submit(event)" novalidate>
  <input type="text" id="name" required minlength="3">
  <input type="email" id="email" required>
  <input type="password" id="password" required>
  <input type="password" id="password_confirm" required>
  <button type="submit">Submit</button>
</form>

<script>
function submit(e) {
  e.preventDefault();
  const inputs = e.target.elements;

  // Get all invalid inputs
  const invalidInputs = [...inputs].filter(input => !input.checkValidity());
  invalidInputs.forEach(input => {
    // Browser-provided message
    console.log(input.validationMessage); 
    // Validation states
    console.log(input.validity); 
  });

  // Validation UX...
  if (invalidInputs.length) {} 
  // Save the data...
  else {} 
}
</script>

When that form is submitted, the browser will silently validate those inputs and update their ValidityState and validationMessage. A CSS :invalid pseudo-property is added too and some events and other stuff not covered here.

By the way, we could have also done:

const invalidInputs = [...e.target.querySelectorAll(':invalid')]
// vs.
const invalidInputs = [...inputs].filter(input => !input.checkValidity());

Furthermore, we can use the Constraint Validation API to implement our custom validation UX and apply additional rules not possible with HTML, like the confirm password case. Let's add that now:

function submit(e) {
  e.preventDefault();
  const inputs = e.target.elements;

  // Apply custom validation first
  const passwordMatch = inputs.password_confirm.value === inputs.password.value;
  inputs.password_confirm.setCustomValidity(passwordMatch ? '' : 'Passwords don\'t match')

  // Get all invalid inputs
  const invalidInputs = [...inputs].filter(input => !input.checkValidity());

  // Validation UX
  if (invalidInputs.length) {
    const errors = invalidInputs.reduce((acc, input) => {
      acc[input.id] = input.validationMessage;
      return acc;
    }, {});
    // Do something with errors...
  } 
  // Save the data...
  else {} 
}

At this point we have all the data we need about the validity of each input to finish our UX, so let's put it all together in a Vue component:

<template>
  <form @submit="submit" novalidate>
    <input type="text" id="name" required minlength="3">
    <small v-if="errors?.name">{{errors.name}}</small>

    <input type="email" id="email" required>
    <small v-if="errors?.email">{{errors.email}}</small>

    <input type="password" id="password" required>
    <small v-if="errors?.password">{{errors.password}}</small>

    <input type="password" id="password_confirm" required>
    <small v-if="errors?.password_confirm">{{errors.password_confirm}}</small>

    <button type="submit">Submit</button>
  </form>
</template>
<script>
export default {
  data() {
    return {errors: null}
  },

  methods: {
    submit(e) {
      e.preventDefault();
      const inputs = e.target.elements;

      // Apply custom validation first
      const passwordMatch = inputs.password_confirm.value === inputs.password.value;
      inputs.password_confirm.setCustomValidity(passwordMatch ? '' : 'Passwords don\'t match')

      // Get all invalid inputs
      const invalidInputs = [...inputs].filter(input => !input.checkValidity());

      // Validation UX
      this.errors = null;
      if (invalidInputs.length) {
        this.errors = invalidInputs.reduce((acc, input) => {
          acc[input.id] = input.validationMessage;
          return acc;
        }, {});
      }
      // Save the data...
      else {}
    }
  }
}
</script>

This approach has a lot of benefits:

  • Less code overall, which means

    • Less to read and debug

    • Smaller bundle

  • No extra dependencies, which means

    • No proprietary API the team has to learn

    • No peer dependency conflicts

    • No chance for migration or deprecation refactoring in the future

    • No extra kilobytes added to the bundle

  • Leverages the web platform, which means

    • Fewer things to test

    • Faster execution

    • Portable code and knowledge

If you're building an app with Vue (or any framework), don't sleep on the web platform. It offers so much and is sometimes taken for granted. Even more, it often goes unused in favor of bigger, slower, messier solutions that only push your codebase further into a corner of unmanageable framework-centric solutions doomed to failure in the long run.

Keep it simple, don't follow fads, just leverage HTML and be happy🙂