The Vue ecosystem is packed with many great form validation libraries, VeeValidate, Vuelidate, and FormKit just to name a few.
Sometimes our use case might not require a full-blown form validation library though and we might already have a schema validation library installed in our project such as Zod or Yup. In that case, a simple Vue composable is all that is needed to provide a great form validation UX.
In this article we'll see how to create a simple Vue composable for form validation with just a few lines of code, that uses Zod under the hood to validate form data.
The idea / how it works
By leveraging Vue's watch
method, our composable will track changes in the provided data object (that holds the form's data) and trigger the safeParseAsync
method from Zod to perform real-time validation. It's worth noting that for big nested objects, there might be a potential performance consideration. However, most of the time, typical forms in a web app contain a limited number of fields, so unless you are validating a huge object, that shouldn't pose an issue. Additionally, the exceptional performance of Vue 3, coupled with robust support for JavaScript proxies in modern browsers, ensures that our composable will be efficient and responsive in the vast majority of practical use cases.
Validation modes
There are many school of thoughts regarding the optimal UX in form validation. Our composable will support two validation modes: one for lazy
(or after-submit) and one for eager
validation, offering a versatile solution that will enable us to select the approach that aligns best with the desired user experience.
Lazy
Lazy
(or after-submit) validation, allows users to fill out the entire form before triggering the validation process. Upon hitting the submit button (i.e. calling the validate
method), the composable will validate the information and return the error messages (if they exist). From that point onwards the composable will report any errors found in realtime.
Eager
In eager
validation, messages are displayed as soon as the users start typing (before even hitting the submit button), encouraging them to take immediate corrective action.
While both methods have their merits, I prefer lazy validation from a UX perspective. By deferring error messages until the form completion stage, we minimize interruptions during data entry, contributing to a smoother, less distracting user experience and reducing potential frustration.
With that out of the way, let's get to the code itself!
useValidation.ts
// Import necessary libraries
import { type ZodTypeAny, z } from 'zod'
// We use `get` and `groupBy` from `lodash` for brevity
import { get, groupBy } from 'lodash-es'
import { ref, watch, toValue, type MaybeRefOrGetter } from 'vue'
export default function <
T extends ZodTypeAny,
U = Record<string, unknown>,
V = Record<string, z.ZodIssue[]>
>(schema: T, data: MaybeRefOrGetter<U>, options?: { mode: 'eager' | 'lazy' }) {
// Merge default options with user-defined options
const opts = Object.assign({}, { mode: 'lazy' }, options)
// Reactive variables to track form validity and errors
const isValid = ref(true)
const errors = ref<V | null>(null)
// Function to clear errors
const clearErrors = () => {
errors.value = null
}
// Function to initiate validation watch
let unwatch: null | (() => void) = null
const validationWatch = () => {
if (unwatch !== null) {
return
}
unwatch = watch(
() => toValue(data),
async () => {
await validate()
},
{ deep: true }
)
}
// Function to perform validation
const validate = async () => {
clearErrors()
// Validate the form data using Zod schema
const result = await schema.safeParseAsync(toValue(data))
// Update validity and errors based on validation result
isValid.value = result.success
if (!result.success) {
errors.value = groupBy(result.error.issues, 'path')
validationWatch()
}
return errors
}
// Function to scroll to the first error in the form
const scrolltoError = (selector = '.is-error', options = { offset: 0 }) => {
const element = document.querySelector(selector)
if (element) {
const topOffset = element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - options.offset
window.scrollTo({
behavior: 'smooth',
top: topOffset
})
}
}
// Function to get the error message for a specific form field, can be used to get errors for nested objects using dot notation path.
const getError = (path: string) => get(errors.value, `${path.replaceAll('.', ',')}.0.message`)
// Activate validation watch based on the chosen mode
if (opts.mode === 'eager') {
validationWatch()
}
// Expose functions and variables for external use
return { validate, errors, isValid, clearErrors, getError, scrolltoError }
}
How to use
The composable takes a Zod object schema and a data object that holds the form data that we want to validate (can be either reactive
or ref
) as parameters and returns ref
s and functions that we can then use in our component. Optionally it can also accept an options object to set the validation mode
.
const { validate, errors, isValid, clearErrors, getError, scrolltoError } = useValidation(validationSchema, form, {
mode: 'lazy',
});
-
validate
- Anasync
function
that triggers the validation process based on the provided Zod schema and the current form data. It returns the validation errors if any, ornull
if the validation is successful. -
errors
- Aref
that holds the validation errors in the form of a grouped object, where each property corresponds to a form field path (e.g.address.city
), and its value is an array of Zod validation issues. -
isValid
- Aboolean
ref
that tracks the overall validity of the form. -
clearErrors
- Afunction
that clears the current validation errors, setting the errorsref
tonull
. Useful when you want to reset the form errors before triggering a re-validation. -
getError
- A helperfunction
to retrieve the error message for a specific form field path. It takes a keypath with dot notation as an argument and returns the first error message for that field. -
scrolltoError
- Afunction
that scrolls the page to the first form field with an error, making it visible to the user. It takes optional parameters for the error selector and scroll options.
Using these returns we can handle form validation, retrieve error details, and manage the user interface based on the validation status, contributing to a robust and user-friendly form validation experience!
Live example
Here is a Stackblitz showcasing the useValidation
composable in action https://stackblitz.com/edit/vue-use-validation-composable-rntqpo?file=src%2FuseValidation.ts.
The form is using components from PrimeVue and includes fields for a user's profile information, featuring nested address details.
That was it!
It's amazing how much functionality you can cram into a small composable using Vue's reactivity primitives! Feel free to experiment with the code, and change it to make it work for your use case.
Thanks for reading!
Top comments (5)
Does this load Zod on the frontend?
Tried it and worked perfectly. Spend days to get a (simple) form working with the primevue/v4 new forms without success.
How do you start with an empty form object?
What is the form has dynamic fields? In that case the schema would change when new fields are being added.
This is similar to the Nuxt UI's Form component. I've been racking my head on how they get to implement stuff like that. Thanks for this