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 . 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:
<!-- Display all errors -->
<p
v-for= "error of v$.$errors"
:key= "error.$uid"
>
<strong> {{ error.$message }} </strong>
</p>
Enter fullscreen mode
Exit fullscreen mode
or
<!-- Display all errors of an individual property -->
<p
v-for= "error of v$.name.$errors"
:key= "error.$uid"
>
<strong> {{ error.$message }} </strong>
</p>
Enter fullscreen mode
Exit fullscreen mode
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 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 like this:
<!-- @/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>
Enter fullscreen mode
Exit fullscreen mode
and a ref that will hold values of our inputs like this:
// @/pages/index.vue
< script setup lang= "ts" >
import type { IForm } from ' @/interface/form.interface '
const form = ref < IForm > ({
firstName : '' ,
lastName : '' ,
email : '' ,
message : '' ,
password : ''
})
</ script >
Enter fullscreen mode
Exit fullscreen mode
If you are not familiar with TypeScript, IForm
is an interface 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:
// @/interface/form.interface.ts
export interface IForm {
firstName : string ;
lastName : string ;
email : string ;
message : string ;
password : string ;
}
Enter fullscreen mode
Exit fullscreen mode
Now import vuelidate's validators along side with custom error messages and let's define an object with validation rules like this:
// @/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 >
Enter fullscreen mode
Exit fullscreen mode
// @/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
}
Enter fullscreen mode
Exit fullscreen mode
And finally let's import vuelidate itself and bind our rules and form to it:
// @/pages/index.vue
< script setup lang= "ts" >
import { useVuelidate } from ' @vuelidate/core '
const v$ = useVuelidate ( rules , form )
</ script >
Enter fullscreen mode
Exit fullscreen mode
To sum it up, our code will look like this:
<!-- @/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>
Enter fullscreen mode
Exit fullscreen mode
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.
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.
// @/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 > )
}
Enter fullscreen mode
Exit fullscreen mode
JavaScript version of this composable will look like this:
// @/composables/useValidationErrors.js
export const useValidationErrors = ( errors ) => {
return errors . reduce (( acc , value ) => {
return { ... acc , [ value . $property ]: value . $message }
}, {})
}
Enter fullscreen mode
Exit fullscreen mode
Now let's wrap our composable with a computed property errors
:
// @/pages/index.vue
< script setup lang= "ts" >
const errors = computed (() => useValidationErrors < IForm > ( v$ . value . $errors ))
</ script >
Enter fullscreen mode
Exit fullscreen mode
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.
If you start typing the email the error will change
Finally let's update our markup of the v-text-field
component with error
and error-messages
props:
<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>
Enter fullscreen mode
Exit fullscreen mode
And voilà!
Gif of the final result is here
Nuxt 3 Minimal Starter
Look at the nuxt 3 documentation to learn more.
Setup
Make sure to install the dependencies:
# yarn
yarn install
# npm
npm install
# pnpm
pnpm install --shamefully-hoist
Enter fullscreen mode
Exit fullscreen mode
Development Server
Start the development server on http://localhost:3000
npm run dev
Enter fullscreen mode
Exit fullscreen mode
Production
Build the application for production:
npm run build
Enter fullscreen mode
Exit fullscreen mode
Locally preview production build:
npm run preview
Enter fullscreen mode
Exit fullscreen mode
Checkout the deployment documentation for more information.
Credits
I have to thank @eduardmavliutov and his colleagues for inspiring me to write this article!