Intro to Client-Side Validation With Yup

Intro to Client-Side Validation With Yup

Using the Platform

If you’ve ever worked on a front-end project, especially one that’s a Single Page Application, you’ve probably needed to do client-side validation. There’s quite a few options we could choose, so let’s do a quick rundown of everything.

Let’s start with the “no JavaScript” approach. There’s actually good web standards around custom client-side form validation using just HTML, so if you don’t care about the browser styling the error message, and your form fields and their validations are simple, you could probably get away with using that.

If you need more customization there’s also the Constraint Validation API, which allows you to validate with JavaScript, bypass the browser error message pop-ups, and style the error messages any way you’d like. On the surface this seems like a good solution! There’s no extra JavaScript dependencies, and we’re using Web Standards™! But, there’s a couple downsides to this API.

First, there can only be one error string associated with an input field at a time. You could concatenate multiple errors into one giant string, I guess, but imagine those bulleted lists of password requirements all in one sentence. That’s gonna be pretty hard to read at a glance.

Second, it’s only available on certain HTML form elements. If you’re using a custom form control, like a custom select element, you’re out of luck.

Lastly, it just feels odd to use an API that’s adding properties and methods to DOM nodes when working with front-end frameworks like Vue or Svelte. In Vue we’re used to creating variables and objects that get put into v-models, and submitting JSON to the backend. We’re rarely accessing the actual DOM nodes of our inputs, except for maybe calling focus() on them.

Our First Attempt

Alright, so maybe we should just validate our JavaScript object that has all our form field values instead. The easiest way would be to just write individual validation functions for each field in your form. Here’s an example of what that might look like using Vue 3:


<script setup>
import { reactive, ref } from 'vue'

const errors = ref({})
const formData = reactive({
  firstName: '',
  lastName: ''
})

function submit() {
  resetErrors()
  validateFirstName()
  validateLastName()
  if (!Object.keys(errors.value).length) console.log('Submitted!')
}

function validateFirstName() {
  if (!formData.firstName) {
    setError('firstName', 'First Name is required.')
  } else {
    removeError('firstName')
  }
}

function validateLastName() {
  if (!formData.lastName) {
    setError('lastName', 'Last Name is required.')
  } else {
    removeError('lastName')
  }
}

function setError(field, errorMessage) {
  if (Array.isArray(errors.value[field])) {
    errors.value[field].push(errorMessage)
  } else {
    errors.value[field] = [errorMessage]
  }
}

function removeError(field) {
  delete errors.value[field]
}

function resetErrors() {
  errors.value = {}
}
</script>

<template>
  <div class="form-wrapper">
    <form @submit.prevent="submit">
      <div class="input-wrapper">
        <label for="firstName">First Name</label>
        <input
          id="firstName"
          name="firstName"
          v-model="formData.firstName"
          :aria-invalid="Boolean(errors.firstName)"
          @input="validateFirstName"
        />
        <template v-if="Boolean(errors.firstName)">
          <span v-for="error in errors.firstName" :key="`firstName-${error}`" class="error">{{
            error
          }}</span>
        </template>
      </div>
      <div class="input-wrapper">
        <label for="lastName">Last Name</label>
        <input
          id="lastName"
          name="lastName"
          v-model="formData.lastName"
          :aria-invalid="Boolean(errors.lastName)"
          @input="validateLastName"
        />
        <template v-if="Boolean(errors.lastName)">
          <span v-for="error in errors.lastName" :key="`lastName-${error}`" class="error">{{
            error
          }}</span>
        </template>
      </div>
      <button type="submit">Submit Form</button>
    </form>
  </div>
</template>

<style scoped>
.form-wrapper {
  margin: 2rem auto 0 auto;
  max-width: 500px;
}

form {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.5rem;
}

input[aria-invalid='true'] {
  outline: 1px solid red;
}

label:has(+ input[aria-invalid='true']) {
  color: red;
}

button {
  grid-column: span 2 / span 2;
}

.input-wrapper {
  display: flex;
  flex-direction: column;
  row-gap: 0.5rem;
}

.error {
  border: 1px solid red;
  background-color: rgba(255, 0, 0, 0.25);
}
</style>

We’ve got our form data in an object called formData, and our input fields populate the firstName and lastName keys of the formData object. As we type into our fields, the values in the formData object update, and then we run our validation functions for our respective fields, validateFirstName and validateLastName. Those functions check to see if there’s a value for our fields, which would be just like a required HTML form field attribute. If the forms are empty, then we call a setError function.

The setError function populates an errors object that we created. We create a key on the object that matches the input field’s name attribute (it could be anything, we’re just using that to keep organized), and the value of that key is our custom error message.

If there are no errors, then our form field’s value is valid, and we delete the field’s key from our errors object.

We’re also displaying the errors below each of the inputs, and if an input is invalid we’re setting aria-invalid attribute. We’re also taking advantage of aria-invalid in our CSS selectors to add some red outlines to give a visual heads-up that things are wrong.

There’s definitely improvements to be made here. We should make the error box its own component, and move the validation logic into its own file so everything is reusable, but if this is your only form in your entire app this approach is fine.

How the Other Half Lives

But most applications have a bunch of forms, and you’d want to have a validation system that’s flexible, easy to write, and better than just fine. If you’ve dabbled in some Rails, then you’ve probably seen how Active Record validations work:


class Person < ApplicationRecord
  validates :name, presence: true, length: { minimum: 3 }
end

In this case, the :name field is required and needs to be at least 3 characters long. Easy to read, easy to add more validations onto a field, and there’s a standard syntax for everything. That’s some pretty good stuff!

If you search for how to do client-side validation in Vue, you’ll find that there are quite a few Vue-specific validation libraries out there. Some of the ones I initially looked into were VeeValidate and Vuelidate, but I’m not interested in having a bunch of “provider” components muddying my templates, and I’m also not interested in a library that’s only available for Vue. I want to learn a library once, and take that knowledge with me into any project.

Yup It Up

One library that checks all our boxes is Yup. Yup is a “schema builder for runtime value parsing and validation.” Which was definitely not written by a marketing department, and also exactly what we’re looking for.

Yup does two main things, it can transform an input and test an input. In our case, we’re not really interested in the transforming, we just want to test that the user filled in what we wanted.

Setting Up the Schema

So let’s start by adding Yup to our Vue project:


npm i yup

We’ll use our code from above and import Yup’s object() method into our component.


<script setup>
import { object } from 'yup'

...

and then we’ll create a new variable called formSchema that creates our Object schema with the imported method.


const formSchema = object({})

The keys of our Object schema are going to match the keys in our formData object, and the values in our schema are also going to be schemas. The “value schemas” are going to have validations we want to perform on the respective formData key’s values. It sounds confusing, but the code will hopefully make it all clear.

Let’s add our first key firstName from formData into our formSchema. And then, since we want firstName to be a String, we’ll first import string() from Yup to create our String schema.

For the value of firstName we’ll call string() to create our String schema, and then chain required() off of that, so that when we validate formData it will check for a value for firstName and throw an appropriate error message if it’s missing.


<script setup>
import { object, string } from 'yup'

...

const formSchema = object({
  firstName: string().required(),
})

We’ll want to do add in lastName too


const formSchema = object({
  firstName: string().required(),
  lastName: string().required()
})

and now our validations are all set up! This is just as readable as Rails’ Active Record, in my opinion!

Testing our Schema

We’ve got our schema set up, but now we need to start validating and throwing errors.

Each schema has a validate() method that we can pass our data into, to verify that our data is correct. Errors will be thrown if our data isn’t valid, and we can do whatever we want with them. In our case we’re going to put them into our errors object that we already created in our initial fine version.

Right now when the user clicks the submit button, we run the submit() function which calls validateFirstName() and validateLastName(). Let’s delete those functions and the calls to them in submit().

Before we move on, here’s what our <script setup> tag looks like so far:


<script setup>
import { reactive, ref } from 'vue'
import { object, string } from 'yup'

const errors = ref({})
const formData = reactive({
  firstName: '',
  lastName: ''
})

const formSchema = object({
  firstName: string().required(),
  lastName: string().required()
})

function submit() {
  resetErrors()
  if (!Object.keys(errors.value).length) console.log('Submitted!')
}

function setError(field, errorMessage) {
  if (Array.isArray(errors.value[field])) {
    errors.value[field].push(errorMessage)
  } else {
    errors.value[field] = [errorMessage]
  }
}

function removeError(field) {
  delete errors.value[field]
}

function resetErrors() {
  errors.value = {}
}
</script>

Let’s first create a function called validateForm() that takes in our schema and our data as parameters. We’ll call our new function from the submit() function.


function submit() {
  resetErrors()
  validateForm(formSchema, formData)
  if (!Object.keys(errors.value).length) console.log('Submitted!')
}

function validateForm(schema, data) {
  // TODO: validate our data and throw errors
}

When we call schema.validate() it will return a Promise, and we’ll just return that from our validateForm() function.

Since we’re going to return a Promise, we’ll need to make submit() async, and add await before our call to validateForm().


async function submit() {
  resetErrors()
  await validateForm(formSchema, formData)
  if (!Object.keys(errors.value).length) console.log('Submitted!')
}

function validateForm(schema, data) {
  return schema.validate()
}

validate() takes in our data as its first parameter, and options as its second. One option we’ll want to add is { abortEarly: false }. If we have multiple errors, then it will give us all of them, instead of stopping at the first one.


function validateForm(schema, data) {
  return schema.validate(data, { abortEarly: false })
}

If you try submitting an invalid form, you should be able to see validate() throwing validation errors in the console!


Uncaught (in promise) ValidationError: 2 errors occurred

Let’s catch those errors and put them into our errors object.


function validateForm(schema, data) {
  return schema.validate(data, { abortEarly: false }).catch((validationError) => {
    validationError.inner.forEach((error) => {
      setError(error.path, error.errors)
    })
  })
}

Since we’re not aborting early, inner is going to be an array of all of our ValidationErrors. We’re going to loop through all of those, and set the path as our key, and an array of errors as our value in our errors object.

First we’ll need to tweak our setError function to assume that it’s always going to get an array.


function setError(field, errorMessages) {
  errors.value[field] = errorMessages
}

If you leave the form fields blank, and submit the form, you should now see our validation errors show up!

One slight refactor we’d probably want to do is move the call to resetErrors() out from submit() and into validateForm(). If we run a validation right now, and everything’s valid, nothing happens to our previous errors because setError only runs if there’s an error. We’ll always want to reset our errors before validation, and having to remember to do that elsewhere is a pain.


async function submit() {
  await validateForm(formSchema, formData)
  if (!Object.keys(errors.value).length) console.log('Submitted!')
}

function validateForm(schema, data) {
  resetErrors()
  return schema.validate(data, { abortEarly: false }).catch((validationError) => {
    validationError.inner.forEach((error) => {
      setError(error.path, error.errors)
    })
  })
}

Tweaking the Errors

In our first version, we were setting the error message manually, and it read:


First Name is required.

but now Yup is setting the error message, and it’s using the path (right now since we don’t have a nested object, our path is the same as the key) from the schema to print out


firstName is required.

If we want to override that path, all we need to do is chain the label() function into our String schema.


const formSchema = object({
  firstName: string().label('First Name').required(),
  lastName: string().label('Last Name').required()
})

and now we’re back to the same as it was before.

If you want to change the message completely, you can just pass in a string to required().


const formSchema = object({
  firstName: string().label('First Name').required('Field must be added.'),
  lastName: string().label('Last Name').required('Field must be added.')
})

and now both errors show as


Field must be added.

If you want to add the path to your custom message, you can add ${path} to your string. (Note: this is not a template string, it’s just a regular string).


const formSchema = object({
  firstName: string().label('First Name').required('${path} must be added.'),
  lastName: string().label('Last Name').required('${path} must be added.')
})

And now our error messages say


First Name must be added.

and


Last Name must be added.

Validating on Input

The last thing we’re missing to be on-par with our fine version is removing errors as they’re resolved.

Yup has another method on its schemas called validateAt() which allows us to import a path and only run validations there.

Let’s create a new function called validateField() and give it a shot.


function validateField(schema, fieldString, data) {
  removeError(fieldString)
  return schema.validateAt(fieldString, data, { abortEarly: false }).catch((validationError) => {
    validationError.inner.forEach((error) => {
      setError(error.path, error.errors)
    })
  })
}

It’s almost exactly the same as our validateForm() function, except we have fieldString as our second parameter. Instead of validate() we’re calling validateAt() and passing the fieldString parameter as our first parameter, our data becomes the second, and our options become the third. Instead of resetErrors(), we’re calling removeError() with our fieldString, because we only want to reset one specific field.

We can now go into our template and call our new validateField() function on our input’s @input events:


<input
  id="firstName"
  name="firstName"
  v-model="formData.firstName"
  :aria-invalid="Boolean(errors.firstName)"
  @input="validateField(formSchema, 'firstName', formData)"
/>

and


<input
  id="lastName"
  name="lastName"
  v-model="formData.lastName"
  :aria-invalid="Boolean(errors.lastName)"
  @input="validateField(formSchema, 'lastName', formData)"
/>

And now we’re exactly where we were before, but now we’ve got an easy way of extending our validations with only a few lines of code.

Let’s make this a little more reusable by moving some of our code into a Vue composable.

Creating a Composable

Let’s create a file called validations.js and fill out some basics. We’ll first just export a function called useValidations().


export function useValidations() {
})

Then we’ll want to move over our errors object, validateForm(), validateField(), setError(), removeError(), and resetErrors() from our component.


export function useValidations() {
  const errors = ref({})

  function validateForm(schema, data) {
    resetErrors()
    return schema.validate(data, { abortEarly: false }).catch((validationError) => {
      validationError.inner.forEach((error) => {
        setError(error.path, error.errors)
      })
    })
  }

  function validateField(schema, fieldString, data) {
    removeError(fieldString)
    return schema.validateAt(fieldString, data, { abortEarly: false }).catch((validationError) => {
      validationError.inner.forEach((error) => {
        setError(error.path, error.errors)
      })
    })
  }

  function setError(field, errorMessages) {
    errors.value[field] = errorMessages
  }

  function removeError(field) {
    delete errors.value[field]
  }

  function resetErrors() {
    errors.value = {}
  }
}

We’ll need to import ref to create our errors object.


import { ref } from 'vue'

and we’ll also want to export all of our functions and our errors object, so we can access them in our component.


export function useValidations() {
  ...

  return {
    errors,
    removeError,
    resetErrors,
    setError,
    validateField,
    validateForm
  }
}

back in our component, we’ll want to import our composable, and then destructure useValidations returned object into new variables we can use throughout our component.


<script setup>
import { useValidations } from '@/validations'
import { reactive } from 'vue'
import { object, string } from 'yup'

const { validateField, validateForm, errors } = useValidations()

const formData = reactive({
  firstName: '',
  lastName: ''
})

const formSchema = object({
  firstName: string().label('First Name').required('${path} must be added.'),
  lastName: string().label('Last Name').required('${path} must be added.')
})

async function submit() {
  await validateForm(formSchema, formData)
  if (!Object.keys(errors.value).length) console.log('Submitted!')
}
</script>

And now we’ve got a nice composable set up that we can import into any other forms in our application.

Loved the article? Hated it? Didn’t even read it?

We’d love to hear from you.

Reach Out

Leave a comment

Leave a Reply

Your email address will not be published. Required fields are marked *

More Insights

View All