DEV Community

Cover image for Validating reusable forms with Vuelidate
Iago Rodrigues
Iago Rodrigues

Posted on

Validating reusable forms with Vuelidate

I'm Iago Rodrigues, frontend developer. I'm from Brazil and I work with Vuejs for more than 2 years as a Freelance. I love coffee and that might be making me crazy, alongside with the the code problems that I find in the way. Like this pretty one here.

For a long time, I've had an issue that was making me crazy. I needed to reuse a form in some views and I needed to validate those forms as well. Now, in the world of modularization, ctrl + c | ctrl+ v a component is going to give you a headache and probably your last paycheck. Let's see how we can solve this issue and, hopefully, keep our belly full of beer 🍺!

The problem

Usually, when we create a CRUD, we need to use the same form in the Create and Show|Edit view. And, in the top of that, we need to validate the fields of the form.

The faster solution for this, is to copy all of the form component (or view, depends on where you will implement it). However, this will create a scar on the best practices of coding, specially DRY which is one of the pillars of Vue.

In this article, you will find a better solution for this, but first, we need to prepare our code to get the tools that will help us do that.

The preparation

First, we need to create a project with Vue.

  npm i -g @vue/cli

  vue create validating-reusable-forms

Choose the following features for the project:

Choosing project's features

If you wanna follow this tutorial thourough, you can clone this repo

GitHub logo oiagorodrigues / validating-reused-forms

This project is a snippet to validate reusable forms.

After the installation is complete, add BootstrapVue (or you can use any css you desire) and Vuelidate to the project.

Tip: when you install a plugin in you project, use YARN for that as it's faster and more reliable. For global installation, use npm.

  yarn add vuelidate bootstrap-vue
// main.js

import Vue from 'vue'
import router from './router'
import store from './store'

import BootstrapVue from 'bootstrap-vue'
import Vuelidate from 'vuelidate'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

import App from './App.vue'

Vue.use(BootstrapVue)
Vue.use(Vuelidate)

[...]

With all the setup done, we can now make it work.

The solution

Create a file named Forms.vue. It can be located in the components folder, but I recommend to create a folder, inside Views, and give the name of your module to it.

In the Form.vue component, define the structure of the form:

// Form.vue

<template>
  <b-form-row class="justify-content-center w-50 mx-auto">
    <b-col cols="12">
      <b-form-group label="Name" label-for="name">
        <b-form-input
          v-model="$v.model.name.$model"
          id="name"
          :disabled="disabled"
          :state="$v.model.name.$dirty ? !$v.model.name.$error : null"
        ></b-form-input>
      </b-form-group>
    </b-col>

    <b-col cols="12">
      <b-form-group label="Age" label-for="age">
        <b-form-input
          v-model="$v.model.age.$model"
          id="age"
          :disabled="disabled"
          :state="$v.model.age.$dirty ? !$v.model.age.$error : null"
        ></b-form-input>
      </b-form-group>
    </b-col>

    <b-col cols="12">
      <b-form-group label="Sexual Orientation" label-for="sexualOrientation">
        <b-form-select
          v-model="$v.model.sexualOrientation.$model"
          id="sexualOrientation"
          :disabled="disabled"
          :options="sexualOptions"
          :state="$v.model.sexualOrientation.$dirty ? !$v.model.sexualOrientation.$error : null"
        ></b-form-select>
      </b-form-group>
    </b-col>

    <b-col cols="12">
      <b-form-group label="Like music?">
        <b-form-radio
          v-model="$v.model.likeMusic.$model"
          name="like-music"
          value="true"
          :disabled="disabled"
          :state="$v.model.likeMusic.$dirty ? !$v.model.likeMusic.$error : null"
        >Yes</b-form-radio>
        <b-form-radio
          v-model="$v.model.likeMusic.$model"
          name="like-music"
          value="false"
          :disabled="disabled"
          :state="$v.model.likeMusic.$dirty ? !$v.model.likeMusic.$error : null"
        >No</b-form-radio>
      </b-form-group>
    </b-col>

    <b-col cols="12">
      <b-form-group label="Describe yourself" label-for="desc">
        <b-form-textarea
          v-model="$v.model.desc.$model"
          id="desc"
          placeholder="Enter something..."
          rows="3"
          max-rows="6"
          :disabled="disabled"
          :state="$v.model.desc.$dirty ? !$v.model.desc.$error : null"
        ></b-form-textarea>
      </b-form-group>
    </b-col>

    <b-col cols="12">
      <b-form-checkbox
        v-model="$v.model.termsOfUse.$model"
        id="termsOfUse"
        name="termsOfUse"
        value="accepted"
        unchecked-value="not_accepted"
        :disabled="disabled"
        :state="$v.model.termsOfUse.$dirty ? !$v.model.termsOfUse.$error : null"
      >I accept the terms and use</b-form-checkbox>
    </b-col>

    <slot class="mt-3" name="action" />

    <b-col class="mt-3" cols="12" sm="4" md="3" lg="2">
      <b-btn variant="outline-danger" block @click="handleReset">Reset</b-btn>
    </b-col>
  </b-form-row>
</template>

Then, import the desired vuelidate rules and create its backbone.

// Form.vue

<script>
import { required } from 'vuelidate/lib/validators'
export default {
  name: 'Form',
  validations: {
    model: {
      name: { required },
      age: { required },
      sexualOrientation: { required },
      desc: { required },
      likeMusic: { required },
      termsOfUse: { required }
    }
  }
}
</script>

In order to make the form reusable, we do not need to generate its data. We will receive it by props.

// Form.vue

<script>
[...]
props: {
    form: {
      type: Object,
      required: true
    }
},
[...]
</script>

Then we need to copy the form prop into our data property, so that we don’t override the data coming from the parent.

// Form.vue

[...]
data () {
  return {
    [...]
    model: { ...this.form },
  }
}

Lastly, in order to trigger Vuelidate’s methods and allow the parent to submit the form with the changed data, we need to define some methods:

// Form.vue

[...]
methods: {
   [...]
    submit() {
      this.$v.$touch()
      return new Promise((resolve, reject) => {
        if (this.$v.$invalid) {
          reject('Error: Invalid Form!')
        }
        return resolve({ ...this.model })
      })
    },
    handleReset() {
      this.$v.$reset()
      this.$emit('reset')
    }
  }

There are some ways for a Child to communicate with its Parent component. One of then (and probably the cleaner one) is to use a promise. We return one resolved or rejected, so the parent can access the form's data without the need to use $children or listen to an event. We reject a string only to return an error, but you don't need to show this message.

If you notice the form's template, we are not calling the submit method anywhere. That's because the parent will call this method when it trigger a submit action.

You might be wondering why the template doesn't have a submit button as well. In order to make this form reusable, we also need to remove any business logic from it. That's why we have only a reset button and a action slot. The parent will implement a button which will do the business logic of its View.

Speaking of reset, in the form we are only triggering the $v.$reset, so that we clear vuelidate's object and rules, and then we are emitting an event, in order to the parent to do something else.

Creating Views

Let's consume the form creating two views. New.vue and Show.vue. The first one will be handling the creation of the data (being a Person, user, book, etc...) and the last will handle the visualization and edition of a single data.

// NewPerson.vue

<template>
  <b-container>
    <Form ref="form" :key="`form_${formId}`" :form="form" @submit.prevent>
      <template v-slot:action>
        <b-col class="mt-3" cols="12" sm="4" md="3" lg="2">
          <b-btn variant="primary" block @click="handleSubmit">Submit</b-btn>
        </b-col>
      </template>
    </Form>
  </b-container>
</template>

We instantiate the Form component passing a ref to it and call the action slot to define a button to do our business logic.

The method will be like this:

// NewPerson.vue

[...]
handleSubmit() {
    this.$refs.form
      .submit()
      .then(payload => {
        // ... do the logic of the component here
      })
      .catch(error => console.error(error))
  },

You can do the same in the Show.vue view. You just need to change the button to something like this:

<template v-slot:action>
   <b-col v-show="disabled" class="mt-3" cols="12" sm="4" md="3" lg="2">
      <b-btn variant="info" block @click="disabled = false">Edit</b-btn>
   </b-col>
   <b-col v-show="!disabled" class="mt-3" cols="12" sm="4" md="3" lg="2">
       <b-btn variant="primary" block @click="handleUpdate">Update</b-btn>
   </b-col>
</template>

And then you can receive this disabled attribute as a props in the Form component.

A little bit more

You might have noticed that we repeated some code in the New.vue and Show.vue views. That is kind of true. We did repeated a few lines in order to implement the same component, but the majority of the implementation is different. That being said, I don't think it'll hurt the DRY practice.

But, to clear your mind, you can create a Partials folder, inside the module, and put a component with the code that we repeated and you can make it work as a wrapper, calling it in the views and just implementing the different parts.

However, try to don't do the logic in the partials component.

Take a look

Wrapping up

This way, we can define the backbones of a form and use it anywhere that we need a similar form. And the parent component doens’t need to worry about validations. That’s all handled in the Form component.

We can deliver reusable, maintanable and clean code following these guidelines.

Find me

oiagorodrigues image

Image credits

Top comments (0)