Validating Schema-Driven forms with Vuelidate

There are times where you may find yourself in need of generating a whole form dynamically. Perhaps you are creating a form that can be modified by the user and the schema is stored in a database. Maybe you find yourself needing to modify the form so often that you decide the best approach is to store the schema in a static file with your code.

In case you need a quick refresher, a schema is a piece of organized data that tells your application how a screen, or form in this case, should be rendered and presented to the user. Usually, you will find these schemas in JSON format, but it is not uncommon to also find them in formats like XML.

Regardless of the reason, you probably want to validate that the final user’s input is what you expect. This helps ensure data consistency and reduce mistakes, but it presents a different problem: validating a form that is dynamically generated.

In this article we’re going to learn how to plug in Vuelidate to an existing schema-driven form and make sure that all the fields are getting correctly validated. These fields will be generated from a JSON file, and will include the required validation rules within it.

I have to assume that you have an understanding of concepts such as Vue’s component component, and a general understanding of what a schema-driven form is. I also have to assume a basic understanding of the Vuelidate library. If you don’t, I hear there’s a book out there that may help you, wink wink, nudge nudge as well as the Next-level Vue course here in Vue Mastery, which will show you the basics of form creation and validation with Vuelidate.

If you want to follow along with the finished project, you can check out/clone the repository at https://github.com/marina-mosti/dynamic-schema-vuelidate

Here’s a little preview of what you’re going to be building today. This whole form is rendered by the schema found in the Setting up section ahead.

https://firebasestorage.googleapis.com/v0/b/vue-mastery.appspot.com/o/flamelink%2Fmedia%2FSchemadriven1.opt.gif?alt=media&token=c6047b8f-1928-4506-9145-c9de0893842a


Setting up

To get started, let’s look at our base setup. We have a JSON file defining our schema.

{
  "firstName": {
    "component": "BaseInput",
    "label": "First Name",
    "validations": {
      "required": {
        "params": null,
        "message": "This field is required"
      },
      "minLength": {
        "params": 3,
        "message": "Please type at least 3 characters"
      }
    }
  },
  "lastName": {
    "component": "BaseInput",
    "label": "Last Name",
    "validations": {
      "required": {
        "params": null,
        "message": "This field is required"
      }
    }
  },
  "email": {
    "component": "BaseInput",
    "label": "Email",
    "type": "email",
    "validations": {
      "required": {
        "params": null,
        "message": "This field is required"
      },
      "email": {
        "params": null,
        "message": "This field needs to be a valid email"
      }
    }
  },
  "favoritePet": {
    "component": "BaseSelect",
    "label": "What's your favorite pet?",
    "options": [
      { "label": "Cat", "value": "cat" },
      { "label": "Dog", "value": "dog" },
      { "label": "Sea Otter", "value": "otter" }
    ],
    "validations": {
      "required": {
        "params": null,
        "message": "This field is required"
      }
    }
  }
}

Notice that each field defines a component, which will load the related component into the form.

More importantly, the schema defines a validations object for each one. This object defines each validation rule per property, such as required, or email.

Inside each one of these validation rules, we have both a params property, which we will use on validation rules like minLength that requires passing a value, and a message field that will define the error message in case the validation fails.


Next up, let’s look at our BaseInput and BaseSelect components. They’re both simple wrappers to make our life easier when dealing with a dynamically generated form.

📃BasicInput.vue

<template>
  <div class="input">
    <label :for="name">{{ label }}</label>
    <input
      v-bind="$attrs"
      :class="{
        'error': hasError,
        'green': !!value && !hasError
      }"
      :aria-describedby="`${name}-error`"
      :value="value"
      :name="name"
      :id="name"
      v-on="{
        ...$listeners,
        input: event => $emit('input', event.target.value)
      }"
    >
    <p :id="`${name}-error`" class="error" v-if="hasError && error">{{ error }}</p>
  </div>
</template>

<script>
export default {
  inheritAttrs: false,
  props: {
    value: {
      type: [String, Number]
    },
    name: {
      type: String,
      required: true
    },
    label: {
      type: String,
      required: true
    },
    error: {
      type: String,
      required: false
    },
    hasError: {
      type: Boolean,
      default: false
    }
  }
}
</script>

<style scoped>
[...]
</style>

There’s nothing particularly interesting about this component, but it’s important to take a close look at the props. We accept a value that will be the v-model value, a name and label that are just strings, and an error strings message and boolean hasError state.


Next, let’s take a peek at the BaseSelect component.

📃BasicSelect.vue

<template>
  <div class="input">
    <label :for="name">{{ label }}</label>
    <select
      :class="{
        'error': hasError,
        'green': !!value && !hasError
      }"
      :aria-describedby="`${name}-error`"
      :name="name"
      :id="name"
      @change="$emit('input', $event.target.value)"
    >
      <option value></option>
      <option
        v-for="opt in options"
        :key="opt.value"
        :value="opt.value"
        :selected="opt.value === value"
      >{{opt.label}}</option>
    </select>
    <p :id="`${name}-error`" class="error" v-if="hasError && error">{{ error }}</p>
  </div>
</template>

<script>
export default {
  props: {
    value: {
      type: [String, Number]
    },
    name: {
      type: String,
      required: true
    },
    label: {
      type: String,
      required: true
    },
    options: {
      type: Array,
      required: true,
      validator (opts) {
        return (
          opts.find(opt => {
            return !opt.hasOwnProperty('label') || !opt.hasOwnProperty('value')
          }) === undefined
        )
      }
    },
    hasError: { type: Boolean, default: false },
    error: { type: String, required: false }
  },
  data () {
    return {
      val: null
    }
  }
}
</script>

<style scoped>
[...]
</style>

The BaseSelect component is very similar to its input counterpart in terms of props, we just add an extra options prop that takes an array with some children to define the <option> tags.


Finally, let’s take a look at the component that we’re going to use to dynamically generate the form. This one is called the DynamicForm.

📃DynamicForm.vue

<template>
    <div>
        <component
            v-for="(field, key) in schema"
            :key="key"
            :name="key"
            :is="field.component"
            v-bind="{ ...field }"
            :value="value[key]"
            @input="update(key, $event)"
        >
        </component>
    </div>
</template>

<script>
export default {
  props: {
    schema: { type: Object, required: true },
    value: { type: Object, required: true }
  },
  data () {
    return {
      form: {}
    }
  },
  methods: {
    update (key, value) {
      this.form[key] = value

      this.$emit('input', {
        ...this.value,
        [key]: value
      })
    }
  },
  created () {
    for (let name in this.schema) {
      this.$set(this.form, name, null)
    }
  }
}
</script>

This component will render all the fields in our loaded schema, in order to allow for that we will use a <component> with a v-for and :is combo. Note that this component gets the schema passed down as a prop to it, which is the JSON file we saw earlier in the article.

Next, notice the created method.

The for...in syntax allows us to obtain the property of each object in our name variable, and loop through it one by one.

We are going to loop each one of the fields that are in the schema and add them dynamically to the this.form state using Vue.set to ensure they are reactive.

If you need a refresher on Vue.set check out this article explaining what Vue.set is and the importance of using it to maintain reactivity.

Finally, instead of a direct v-model we’ve chosen to use the value binding and to call an update method on the component. Notice that inside the update method we are going to first assign the updated value to our this.form state, and then we’re going to $emit an input event that includes the current value that the parent is binding to, and we overwrite the value of the key that is being updated.

Wondering about the [key]: value syntax? JavaScript allows us to dynamically target the property of an object by using array syntax. For example, if our key was equal to firstName and our value to Marina this bit of code would be interpreted as firstName: Marina.

Here’s the first gotcha: did you notice that this.form at this point is virtually useless? We are keeping an internal copy of the whole state of the form because in a bit we’re going to use that to hook it up to Vuelidate.


Injecting Vuelidate

Now that our demo project is set up, we’re ready to finally start making changes to our files so that our component can use and understand our validation rules.

Make sure that you add Vuelidate to your project by doing npm install vuelidate or yarn add vuelidate, and open up DynamicForm.vue.

The first step in any Vuelidate-powered form is to add a validations property to the component that will be handling all the validations.

📃DynamicForm.vue

[...]

<script>
export default {
  [...]
	validations () {
    return { form: validationRules(this.schema) }
  }
}
</script>

Vuelidate is expecting you to return a an object identical your state that includes the validation rules for each part of your state. We currently have a form property with our fields inside, so we have to re-create it but instead of the definition of each field, we will return the rules.

In this case we have to return an object that has a single property form, and inside that form property a dynamically generated object with all the fields in our schema.

The tricky part comes here. Remember that inside our schema.json we are declaring our rules inside our validations property, using props and values? Vuelidate is actually expecting us to be passing down functions, most of them already built-in to the library.

What we’re going to do here is create a Parser. This parser will be a regular .js file, and will export a single function validationRules, we will pass down our schema as the only parameter, and it will parse it down to the format that Vuelidate is expecting.

Ready? Create a ValidationsParser.js file.

📃ValidationsParser.js

import * as Validators from 'vuelidate/lib/validators'

export const validationRules = schema => {
  return Object.keys(schema).reduce((rules, elementName) => {
    const item = schema[elementName]
    if (!item.hasOwnProperty('validations')) return rules

    const validations = {}
    for (let rule in item.validations) {
      const params = item.validations[rule].params

      if (params) {
        validations[rule] = Validators[rule](params)
      } else {
        validations[rule] = Validators[rule]
      }
    }

    rules[elementName] = validations
    return rules
  }, {})

First, we’re going to import the validation functions from Vuelidate.

We export a single function, validationRules, which takes the schema just as we saw before. Now bare with me because there’s a lot happening here!

First, we are going to get the keys of the schema object, because in our JSON file, the keys for each of the properties are each of the fields. So each key will be the field name.

Once we have an array of these keys, we will use Array.reduce on it to create the rule set.

Inside the reduce loop, we extract the item we’re working on and assign it to item for easy access. We first check if this new item has a validations property, and if it doesn’t we won’t process it.

Now, we are going to loop through each of the validations that we find inside the validations object, because a field can have multiple validations.

We extract into a params constant for easy access to the rule’s params and we check if it has any rules. If it does, we have to make sure that we pass them down and execute the validation function. So inside the if/else check we are going to find the current rule inside our imported Validators, and then pass in the params if needed.

We store the result of this call in the validations object that we created earlier, and finally assign the validations object itself to the rules that we’re building.

Whoa. Let’s put this to use, that way you can explore the results in the browser.

Go back to DynamicForm and import the file at the top of the script. Our validations function is already using it so that’s all we have to do with the parser.

📃DynamicForm.vue

<script>
import { validationRules } from '@/parsers/ValidationParser'

export default {
  [...]
	validations () {
    return { form: validationRules(this.schema) }
  }
}
</script>

Trigger validations

The next step is to actually tell our DynamicForm how to use the new validations, or rather, when.

Remember the update method that we created in the beginning? We’re going to modify it to fire the $touch events that Vuelidate needs, to know when to revalidate and recalculate each field.

📃DynamicForm.vue

<script>
import { validationRules } from '@/parsers/ValidationParser'

export default {
  [...]
	methods: {
     update (key, value) {
      this.form[key] = value
      this.$v.form[key].$touch()

      this.$emit('input', {
        ...this.value,
        [key]: value
      })

      this.announceStatus()
    },
    announceStatus () {
      this.$emit('status', {
        invalid: this.$v.$invalid
      })
    }
  }
}
</script>

First we’re modifying the update method by adding a call to $touch on the respective Vuelidate form element. This will make sure that particular input’s validation rules that we created earlier are run, and any state is updated within $v.

We’re also going to call this new function announceStatus that will $emit the overall status of our form, that way our parent can listen to status changes and react accordingly.


Displaying the errors

As you remember, both of our components are capable of displaying errors to the user through the hasError and error props, but so far we haven’t used either.

Let’s start by updating our component with the hasError property, since that is pretty straightforward.

📃DynamicForm.vue

<template>
    <div>
        <component
            v-for="(field, key) in schema"
            :key="key"
            :name="key"
            :is="field.component"
            v-bind="{ ...field }"
            :value="value[key]"
            @input="update(key, $event)"
            :hasError="$v.form[key].$error"
        >
        </component>
    </div>
</template>

[...]

We have added the :hasError binding. We look inside our $v object for the corresponding element’s model, and we pass down the $error status. If the component has a validation problem, this will be true and the component will receive the status as a boolean.

Now, we need to figure out a way to pass down the custom messages for errors that we defined inside our schema for each of the validations. For that we’re going to use a computed property called errorMessages.

📃DynamicForm.vue

<template>
    <div>
        <component
            v-for="(field, key) in schema"
            :key="key"
            :name="key"
            :is="field.component"
            v-bind="{ ...field }"
            :value="value[key]"
            @input="update(key, $event)"
            :hasError="$v.form[key].$error"
            :error="errorMessages[key]"
        >
        </component>
    </div>
</template>

<script>
import { validationRules } from '@/parsers/ValidationParser'

export default {
  [...]
  computed: {
    errorMessages () {
      const validations = this.$v.form

      return Object.keys(this.schema).reduce((messages, key) => {
        const rules = this.schema[key].validations
        const rulesKeys = Object.keys(this.schema[key].validations)
        const validator = validations[key]

        if (!validator) return messages

        for (let rule of rulesKeys) {
          if (validator[rule] !== false) continue

          messages[key] = rules[rule].message
          return messages
        }

        return messages
      }, {})
    }
  },
  [...]
}
</script>

First, notice that the template also changed. We are now passing down the :error binding, we will use this new computed property errorMessages that will give us an object with keys for properties, and messages for values.

Let’s go deep into the errorMessages function.

First we store this.$v.form which holds all of our current form status into a validations const for readability.

We are going to use the same approach that we did on the parser here, and loop through the keys of the schema and reduce the array.

First, we extract the current key rules into a constant.

After, we extract the names of the validations as ruleKeys.

Finally, we look inside of our current validations to see if the validator exists, if not, we move on.

Now, we loop through the rulesKeys (rule names) and for each rule we are going to check if the rule in the validator is false, if this happens it means that the validation for this particular rule is failing. For example, the user didn’t complete the required field.

Finally, we assign the correct message to the messages object using the key and return.

Still with me? We’re almost done! At this point we have a fully working component that renders a validated schema driven form, let’s just put it to use!


Using the component

Open up App.vue or whatever component you’re going to use as the parent, and import our DyanmicForm component, as well as the schema.

📃App.vue

<template>
  <div id="app">
      <form @submit.prevent="submitForm">
        <DynamicForm
          :schema="schema"
          v-model="form"
          @status="saveStatus"
          ref="dynamicForm"
        />

        <pre>{{ form }}</pre>
        <button type="submit">SEND!</button>
      </form>
  </div>
</template>

<script>
import schema from './schema.json'
import DynamicForm from './components/DynamicForm'

export default {
  name: 'app',
  components: { DynamicForm },
  data () {
    return {
      schema,
      form: {},
      status: null
    }
  },
  methods: {
    submitForm () {
      this.$refs.dynamicForm.validate()
      if (this.status.invalid) {
        return
      }

      alert('Form submitted')
    },
    saveStatus (status) {
      this.status = status
    }
  }
}
</script>

In the template, we are wrapping our DynamicForm inside a form tag and catching the submit event. We pass in the loaded schema through the property, and v-model it to a local form state.

We are also going to listen to the status events that we created earlier, and add ref to the component—I’ll explain this in a minute.

In the script, you can see that in the saveStatus method we’re just updating the status on the local state.

In the submitForm however, we’re going to have to somehow check to see if the WHOLE form is valid. This means we’re going to have to expose a method inside our DynamicForm component called validate that does this exact job. It should trigger our status update in the end, so that we prevent the submission of an invalid form.

Now let’s go back to DynamicForm and add this method.

📃DynamicForm.vue

<script>
import { validationRules } from '@/parsers/ValidationParser'

export default {
  [...]
  methods: {
    [...]
    validate () {
      this.$v.$touch()
      this.announceStatus()
    },
    announceStatus () {
      this.$emit('status', {
        invalid: this.$v.$invalid
      })
    }
  },
  [...]
}
</script>

As you can see, the validate method is quite simple. It asks Vuelidate to trigger $touch on the whole form, and then uses the announceStatus method to re-emit the status of the form to the parent.

Finally our dynamic, validated form is ready!

https://firebasestorage.googleapis.com/v0/b/vue-mastery.appspot.com/o/flamelink%2Fmedia%2FSchemadriven2.opt.png?alt=media&token=c88b946c-0da4-432f-8987-e7bc868ea2ab


Wrapping up

Validating schema generated forms can seem highly daunting at first, it requires some careful planning and understanding of your schema and of the inner working of the validation library that you’re using. Thankfully Vuelidate’s ability to hook into any type of state makes this a relatively simple task!

Download the cheatsheets

Our Vue essentials, Vue 3, and Nuxt.Js cheat sheets save you time and energy by giving you essential syntax at your fingertips.