A simple Vue form validation composable with Zod
Tags: vue, webdev, javascript
Published: 2023-12-09 17:54:43

The Vue ecosystem is packed with many great form validation libraries, [VeeValidate](https://vee-validate.logaretm.com/v4/), [Vuelidate](https://vuelidate-next.netlify.app/), and [FormKit](https://formkit.com/) 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](https://zod.dev/) or [Yup](https://github.com/jquense/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`
```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>(schema: T, data: MaybeRefOrGetter<Record<string, unknown>>, 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<Record<string, z.ZodIssue[]> | 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`.

```ts
const { validate, errors, isValid, clearErrors, getError, scrolltoError } = useValidation(validationSchema, form, {
  mode: 'lazy',
});
```

- `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](https://stackblitz.com/edit/vue-use-validation-composable?file=src%2FApp.vue) showcasing the `useValidation` composable in action https://stackblitz.com/edit/vue-use-validation-composable?file=src%2FApp.vue. 
The form is using components from [PrimeVue](https://primevue.org/) 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!