Handling form errors with vuelidate in VueJS 3.0
Tags: vue, typescript, javascript
Published: 2022-10-05 15:18:35

## Intro

Hey everyone!

In today's article I will show you a clean and simple approach on handling form errors in VueJS 3.0 with [vuelidate](https://vuelidate-next.netlify.app/). Additionally I will be using NuxtJS 3.0 and TypeScript because they are some of my most favourite tools to use in modern frontend environment.

## Motivation

Vuelidate gives us a few examples on how to display validation messages:

```html
<!-- Display all errors -->
<p
  v-for="error of v$.$errors"
  :key="error.$uid"
>
<strong> {{ error.$message }} </strong>
</p>
```

or

```html
<!-- Display all errors of an individual property -->
<p
  v-for="error of v$.name.$errors"
  :key="error.$uid"
>
<strong> {{ error.$message }} </strong>
</p>
```

And its fine if your markup is pretty simple, but what if you have a lot of inputs or you are using a ui framework that comes with an error message prop like [Vuetify's](https://next.vuetifyjs.com/en/api/v-textarea/#props-error-messages) textarea component does. If you are still interested then welcome aboard and lets make form validation great again!

## Coding

Let's create a simple form using [Vuetify 3.0](https://next.vuetifyjs.com/en/) like this:

```html
<!-- @/pages/index.vue -->

<v-form @submit.prevent="validate()" class="form">
  <v-container>
    <v-row>
      <v-col cols="12">
        <v-text-field v-model="v$.firstName.$model" label="First Name" variant="solo"/>
      </v-col>

      <v-col cols="12">
        <v-text-field v-model="v$.lastName.$model" solor label="Last Name" variant="solo" />
      </v-col>

      <v-col cols="12">
        <v-text-field v-model="v$.email.$model" label="E-mail" variant="solo"/>
      </v-col>

      <v-col cols="12">
        <v-textarea v-model="v$.message.$model" label="Message" variant="solo"/>
      </v-col>

      <v-col cols="12">
        <v-text-field v-model="v$.password.$model" label="Password" variant="solo"/>
      </v-col>

      <v-col cols="12">
        <v-btn type="submit" color="success">Submit</v-btn>
      </v-col>
    </v-row>
  </v-container>
</v-form>
```

and a [ref](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#reactive-variables-with-ref) that will hold values of our inputs like this:

```vue
// @/pages/index.vue

<script setup lang="ts">
import type { IForm } from '@/interface/form.interface'

const form = ref<IForm>({
  firstName: '',
  lastName: '',
  email: '',
  message: '',
  password: ''
})
</script>
```

If you are not familiar with TypeScript, `IForm` is an [interface](https://www.typescriptlang.org/docs/handbook/interfaces.html) that helps us with type checking. If you are using JavaScript just remove the `<IForm>` and you are fine. For those who uses TypeScript, my interface looks like this:

```typescript
// @/interface/form.interface.ts

export interface IForm {
    firstName: string;
    lastName: string;
    email: string;
    message: string;
    password: string;
}
``` 

Now import vuelidate's validators along side with custom error messages and let's define an object with validation rules like this:

```vue
// @/pages/index.vue

<script setup lang="ts">
import { required, helpers, email } from '@vuelidate/validators'

import { FORM_INVALID_EMAIL, FORM_REQUIRED_FIELD } from '@/helpers/messages'

const rules = {
  firstName: {
    required: helpers.withMessage(FORM_REQUIRED_FIELD, required)
  },
  lastName: {
    required: helpers.withMessage(FORM_REQUIRED_FIELD, required)
  },
  email: {
    required: helpers.withMessage(FORM_REQUIRED_FIELD, required),
    email: helpers.withMessage(FORM_INVALID_EMAIL, email)
  },
  message: {
    required: helpers.withMessage(FORM_REQUIRED_FIELD, required)
  },
  password: {
    required: helpers.withMessage(FORM_REQUIRED_FIELD, required)
  }
}
</script>
```

```typescript
// @/helpers/messages.ts
const FORM_REQUIRED_FIELD = 'This field is required'
const FORM_INVALID_EMAIL = 'This email is invalid'

export {
  FORM_REQUIRED_FIELD,
  FORM_INVALID_EMAIL
}

```

And finally let's import vuelidate itself and bind our rules and form to it:

```vue
// @/pages/index.vue
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'

const v$ = useVuelidate(rules, form)
</script>
```

To sum it up, our code will look like this:

```html
<!-- @/pages/index.vue -->

<template>
  <main class="main">
    <v-form @submit.prevent="validate()" class="form">
      <v-container>
        <v-row>
          <v-col cols="12">
            <v-text-field v-model="v$.firstName.$model" label="First Name" variant="solo"/>
          </v-col>

          <v-col cols="12">
            <v-text-field v-model="v$.lastName.$model" solor label="Last Name" variant="solo" />
          </v-col>

          <v-col cols="12">
            <v-text-field v-model="v$.email.$model" label="E-mail" variant="solo"/>
          </v-col>

          <v-col cols="12">
            <v-textarea v-model="v$.message.$model" label="Message" variant="solo"/>
          </v-col>

          <v-col cols="12">
            <v-text-field v-model="v$.password.$model" label="Password" variant="solo"/>
          </v-col>

          <v-col cols="12">
            <v-btn type="submit" color="success">Submit</v-btn>
          </v-col>
        </v-row>
      </v-container>
    </v-form>
    </main>
</template>

<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'

import { required, helpers, email } from '@vuelidate/validators'

import { FORM_INVALID_EMAIL, FORM_REQUIRED_FIELD } from '@/helpers/messages'

import type { IForm } from '@/interface/form.interface'

const form = ref<IForm>({
  firstName: '',
  lastName: '',
  email: '',
  message: '',
  password: ''
})

const rules = {
  firstName: {
    required: helpers.withMessage(FORM_REQUIRED_FIELD, required)
  },
  lastName: {
    required: helpers.withMessage(FORM_REQUIRED_FIELD, required)
  },
  email: {
    required: helpers.withMessage(FORM_REQUIRED_FIELD, required),
    email: helpers.withMessage(FORM_INVALID_EMAIL, email)
  },
  message: {
    required: helpers.withMessage(FORM_REQUIRED_FIELD, required)
  },
  password: {
    required: helpers.withMessage(FORM_REQUIRED_FIELD, required)
  }
}

const v$ = useVuelidate(rules, form)

const validate = async () => {
    const result = await v$.value.$validate() 

    if (!result) {
        return
    }

    alert('Success! Sending to API')
}
</script>
```

If you press the submit button without filling in the form and open Vue DevTools, in the `v$` object you will find `$errors` property containing errors for each corresponding input.


![Vuelidate errors](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sbhue8vhur3bnddxeqfw.png)


![Vulidate single error](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o7dikpw3nt53paazmcv5.png)

Each object in the `$errors` array has a `$message` property with a custom error message that we have defined in `@/helpers/messages.ts`. What we have to do here, is to iterate through `$errors` array and create a map of errors.

Let's create a composable `useValidationErrors.ts`, this function takes an array of errors from vuelidate and using `reduce` method creates an object of key-value pairs, where our `key` is a name of the validated property and our `value` is a custom defined message.

```typescript
// @/composables/useValidationErrors.ts

import type { ErrorObject } from '@vuelidate/core'

export const useValidationErrors = <T extends Record<keyof T, string>>(errors: ErrorObject[]): Record<keyof T, string> => {
  return errors.reduce((acc, value) => {
    return { ...acc, [value.$property]: value.$message }
  }, {} as Record<keyof T, string>)
}
```

JavaScript version of this composable will look like this:

```javascript
// @/composables/useValidationErrors.js

export const useValidationErrors = (errors) => {
  return errors.reduce((acc, value) => {
    return { ...acc, [value.$property]: value.$message }
  }, {})
}
```

Now let's wrap our composable with a computed property `errors`:

```vue
// @/pages/index.vue

<script setup lang="ts">
const errors = computed(() => useValidationErrors<IForm>(v$.value.$errors))
</script>
``` 

* Note that I haven't imported my composable anywhere, Nuxt 3.0 does that for you.

* For JavaScript developers: just remove `<IForm>` from `useValidationErrors`

If you click on the submit button again now in the Vue DevTools you will see our `errors` computed property being populated.


![errors computed property](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cizcqu1tjju1px2zyfbv.png)

If you start typing the email the error will change


![errors computed property changed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6lj6jt8rishaxkv2qoih.png)

Finally let's update our markup of the `v-text-field` component with `error` and `error-messages` props:

```vue
<v-form @submit.prevent="validate()" class="form">
      <v-container>
        <v-row>
          <v-col cols="12">
            <v-text-field
              v-model="v$.firstName.$model"
              label="First Name"
              variant="solo"
              :error="v$.firstName.$error"
              :error-messages="errors.firstName"
            />
          </v-col>

          <v-col cols="12">
            <v-text-field
              v-model="v$.lastName.$model"
              label="Last Name"
              variant="solo"
              :error="v$.lastName.$error"
              :error-messages="errors.lastName"
            />
          </v-col>

          <v-col cols="12">
            <v-text-field
              v-model="v$.email.$model"
              label="E-mail"
              variant="solo"
              :error="v$.email.$error"
              :error-messages="errors.email"
            />
          </v-col>

          <v-col cols="12">
            <v-textarea
              v-model="v$.message.$model"
              label="Message"
              variant="solo"
              :error="v$.message.$error"
              :error-messages="errors.message"
            />
          </v-col>

          <v-col cols="12">
            <v-text-field
              v-model="v$.password.$model"
              label="Password"
              variant="solo"
              :error="v$.password.$error"
              :error-messages="errors.password"
            />
          </v-col>

          <v-col cols="12">
            <v-btn
              type="submit"
              color="success"
            >
              Submit
            </v-btn>
          </v-col>
        </v-row>
      </v-container>
    </v-form>
```

And voilà!

Gif of the final result is [here](https://github.com/andynoir/vuelidate-handling-errors/blob/main/result.gif)

{% embed https://github.com/andynoir/vuelidate-handling-errors %}

## Credits

I have to thank @eduardmavliutov and his colleagues for inspiring me to write this article!