Wes Hatch

front-end developer

Validation in Vue

tl;dr Instead of a promise-based API for every asynchronous field in a form, a much more flexible approach is a single async setError method for the entire reactive form. This gives greater flexibility, significantly less code, and can be useful when integrating back-end validation messaging.

Play with it HERE

This post originated from a need for a Vue 3 validation library. At the time of writing, there were not a lot of options, so I authored my own. It’s inspired in part by Vuelidate, but I’m sure the landscape has certainly changed since. However, a few unique features of this one are:

Overview

The validation framework described herein leverages the Vue 3 composition API to produce a reactive form object. This object can then be consumed by any page component and used to automatically validate any user-entered data.

To get up and running, only two things are required: a set of values, and a set of rules to validate them. The values are usually the component’s data, while the rules are a JSON-schema that define which inputs need validation, and how.

Proof of concept

A working example can be found HERE.

Setting up a validation schema is easy. Its structure is flexible enough such that we can bootstrap it in a variety of different ways, depending on the use-case.

Option 1

Define both data values and its validation rules upfront, in an external schema.

// schemas/validation.js
const exampleValues = { /* the form data */ };
const exampleSchema = { /* validation rules for the above */ };

export function useExampleValidation() {
return useValidation(exampleSchema, exampleValues);
}

Then, in your component:

// exampleComponent.vue
import { useExampleValidation } from '@/path/to/schemas';

export default {
setup() {
const { form } = useExampleValidation();
return { form };
};
}
Tip (Or if you're using Vue 2 + composition API)
import { useExampleValidation } from '@/path/to/schemas';
const { form } = useExampleValidation();

export default {
  computed() {
    form: () => form
  }
  // ...

The advantage of this set-up is simplicity; it’s clean, easy, and quick. The disadvantage is that the component’s data is not readily visible to the developer, which may then be opaque in the template.

Option 2

Conversely, you may also set up the validation composable thusly:

// schemas/validation.js
const exampleSchema = { /* validation rules */ };

export function useExampleValidation() {
return useValidation(exampleSchema);
}
Note
  • no values are passed in; they'll be hydrated on component instantiation

Then, in your component:

// exampleComponent.vue
import { useExampleValidation } from '@/path/to/schemas';

export default {
setup() {
const { form, setValues } = useValidation(schema);
const vals = setValues({
name: '',
email: '',
// ...
});

return {
form
}
}
}

In this case, the vals returned from setValues may be still be used in the component. The object is now reactively bound, and may be manipulated in parallel. Modifying form (or even vals) will trigger validation updates.

Tip (If you're using Vue 2 + composition API)
import { useExampleValidation } from '@/path/to/schemas';

const { form, setValues } = useExampleValidation();

export default {
  data() {
    return {
      form: setValues({
        name: '',
        email: '',
        // ...
      }),
    };
  },

  computed: {
    form: () => form,
  },

Portability

Once created, the composable creates a reactive object that may be bound to the view. The interesting thing is that this object may be used elsewhere in your application, while still retaining reactive validation bindings to the component. Put another way, if we modify the validation composable elsewhere, the view will still automically update as desired.

This offers the unique opportunity to import the composable in your app’s store or actions, where it may be used to hydrate server-side errors; any field or validation error that is updated here will automatically be surfaced in the template, with no further error handling needed.

This is accomplished with webpack’s conditional imports, illustrated below. First, we create the validation object:

// schemas/index
const schema = { ... };
const values = { ... };
const exampleForm = useValidation(schema, values);

export { exampleForm };

…and use it in a component

// exampleComponent.vue
import { exampleForm } from '@/path/to/schemas';

export default {
setup() {
const { form } = exampleForm();
return {
form
}
}
}

Now, finally, in an action:

export const exampleAction = async ({ commit }) => {
try {
const exampleData = await api.settings.getExampleData();
// ...
} catch (error) {
import('@/path/to/schemas/exampleForm') // WEBPACK conditional import.
.then((exampleForm) => {
exampleForm.setErrors(error);
});
}
};

Note that we conditionally load the module and hydrate only upon any error(s) originating from the server. That’s it. We can now surface server errors directly in the page from here*.

Details

useValidation creates a reactive form validation object. The generated object matches the shape of the validation schema, while each field is additionally decorated with the following five properties: $model, $error, $dirty, $invalid and $errors. For example:

"$model": "horace", // the data to be validated
"$dirty": false,
"$invalid": false, // if _any_ of the validation rules fail
"$error": false, // helper for: $invalid && $dirty
"$errors": [ ... ]

Additionally, all the validation rules for each field are provided as computed properties. In the following example, the field has three validators (is required, is an email, and meets the minimum length) and the response of each:

"required": true,   // passes required check
"minLength": false, // does not meet minLength criteria
"email": true, // passes email validation

Note the similarities with vuelidate, from which this structure was borrowed.

Why not async…?

You may notice that there is no $pending nor it’s equivalent, here. While many frameworks have provisions for a Promise-based validator per field, personally, I don’t think it’s necessary. If you need to hit an API for a valdiation, you’ll be authoring an async request to do so regardless.

The approach with this framework is to use a single entry point, setErrors, for any asyncronous errors received from the server on its response. The setErrors function can then handle all responses with ambivalence – whether they’re generated server-side or client-side, mapping each back to the respective field.

A Form-field helper Component

It’s easy to create a form field helper that can be used to wrap common form elements – selects, inputs, checkboxes, etc. Here, we create a simple wrapper that provides a slot for the aforementioned components, which normalizes the display of hint text, form labels, and errors.

<div :class="['input', {'has-error': hasError}]">
<label class="input-label" v-if="label"></label>
<slot v-bind="$attrs"></slot>
<span v-if="text" class="input-hint text-small"></span>
</div>
export default {
name: 'z-field',
props: {
hint: '',
label: '',
errors: () => [],
disabled: false
},

computed: {
hasError() {
return !!(this.errors && this.errors.length);
},

text() {
const { errors, hint } = this;
return errors.length ? `${ errors[0].$message }` :
hint ? hint :
'';
},
},
};
.has-error .input-label {
animation: 1s shake 1;
}

References:

The Validatable idea draws inspiration from multiple sources.


* There is the question of "mapping" the error back to the field. We presuppose two things: that the server response is in the JSON-error format (the framework will unwrap it and apply it to the corresponding field automatically if so), and that the pointer in the JSON-error is named the same as the field.