DEV Community

Cover image for A simple Vue form validation composable with Zod
Giannis Koutsaftakis
Giannis Koutsaftakis

Posted on • Edited on

A simple Vue form validation composable with Zod

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 }
}
Enter fullscreen mode Exit fullscreen mode

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 refs 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',
});
Enter fullscreen mode Exit fullscreen mode
  • validate - An async function that triggers the validation process based on the provided Zod schema and the current form data. It returns the validation errors if any, or null if the validation is successful.
  • errors - A ref 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 - A boolean ref that tracks the overall validity of the form.
  • clearErrors - A function that clears the current validation errors, setting the errors ref to null. Useful when you want to reset the form errors before triggering a re-validation.
  • getError - A helper function 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 - A function 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 (4)

Collapse
 
jdgamble555 profile image
Jonathan Gamble

Does this load Zod on the frontend?

Collapse
 
bobo12345 profile image
Bobo12345

How do you start with an empty form object?

Collapse
 
redcodemohammed profile image
Mohammed

What is the form has dynamic fields? In that case the schema would change when new fields are being added.

Collapse
 
the_olusolar profile image
Solar

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