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 valueminlength="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🙂