Separating Core Logic and Framework Integrations (Part 2)


This article is available as a screencast!

This article is part two, where we integrate the framework agnostic validate library we designed in the previous article with Vue 3 via a useForm composable.

You can find the previous article here and the final source code here.

Designing the Integration Layer

Like the previous article, we will spend a bit of time planning the API and integration before start coding. Examining the prior art shows several successful ways Vue libraries have done form validation:

Vuelidate

Vuelidate provides validation via a validations key which you add to your component:

export default {
  validations: {
    name: {
      required,
      minLength: minLength(4)
    }
  },
  methods: {
    submit() {
      this.$v.$touch()
      if (this.$v.$invalid) {
        // don't submit
      }
    }
  }
}

You can disable a button based on the submitStatus flag:

<button class="button" type="submit" :disabled="submitStatus === 'PENDING'">Submit!</button>

VeeValidate

VeeValidate has changed a lot since I last used it, but the concept remains the same: it moves the validation to the template, using a ValidationProvider and ValidationObserver API. I have no idea how this works under the hook. Anyway, it looks like this:

<ValidationObserver v-slot="{ invalid }">
    <form @submit.prevent="onSubmit">
      <ValidationProvider name="E-mail" rules="required|email" v-slot="{ errors }">
        <input v-model="email" type="email">
        <span>{{ errors[0] }}</span>
      </ValidationProvider>
    <button type="submit" :disabled="invalid">Submit</button>
  </form>
</ValidationObserver>

We are going to go for something a bit closer to Vuelidate, and declare our rules in the script tag, via a useForm composable. This is the goal:

<template>
  <form>
    <input v-model="form.username.ref" />
    <div v-if="form.username.error">
      {{ form.username.error }}
    </div>
      <button :disabled="!form.valid">submit</button>
  </form>
</template>

<script lang="ts">
export default defineComponent({
  setup() {
    const { form } = useForm([
      {
        name: 'username',
        value: '',
        rules: [hasLength({ min: 2, max: 3 }), isRequired()],
      }
    ])

    return {
      form
    }
  }
})
</script>

Each input will be created on the reactive form object, which is returned from useForm. form will also have a valid property, which we can use to disable the submit button until the form in valid. Each input will have an error property and a ref property. I like this because it’s nothing special or and there is no magic going on - it’s just an object using Vue’s reactivity API (which is somewhat magic, but at least our library isn’t adding an additional magic).

The MVP Implementation

Before making the useForm hook extremely robust and flexible, let’s start simple - just get it working with one input. Here is the minimal implementation:

export const useForm = (rules: Rule[]) => {
  const form = {
    valid: ref(false),
    username: {
      ref: ref(''),
      error: ref<string | null>(null)
    }
  }

  watch(form.username.ref, val => {
    const status = validate(val, rules)
    if (!status.valid) {
      form.username.error.value = status.message
    } else {
      form.username.error.value = null
    }
  })

  return {
    form
  }
}

This works - it can be used like this:

<template>
  <form>
    <input v-model="form.username.ref" />
    <div v-if="form.username.error">
      {{ form.username.error }}
    </div>
    <button :disabled="!form.valid">submit</button>
  </form>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { hasLength, useForm } from './validation'

export default defineComponent({
  name: 'App',

  setup() {
    const { form } = useForm(
      [hasLength({ min: 2, max: 3 })]
    )

    return {
      form
    }
  }
})
</script>

And looks like this:

We haven’t implemented form.valid yet, though. Our current approach is a nice prototype, but clearly this won’t scale for multiple inputs - we need something a bit more generic.

Let’s start off by defining the payload for useForm with an interface:

interface FormInput {
  name: string
  value: string
  rules: Rule[]
}

We will require the user pass a name for each input - we can use this dynamically assign the inputs to the form object we create in useForm. The new API now looks like this:

const useForm = (inputs: FormInput[]) => {
  // ...
}

// Usage
const { form } = useForm([
  {
    name: 'username',
    value: '',
    rules: [hasLength({ min: 2, max: 3 })]
  }
])

Now, we need to loop over each of the inputs and create a ref and error property on form:

export const useForm = (inputs: FormInput[]) => {
  const form = {
    valid: ref(false),
  }

  for (const input of inputs) {
    form[input.name] = {
      ref: ref(input.value),
      error: ref<string | null>(null)
    }

    watch(form[input.name].ref, val => {
      const status = validate(val, input.rules)
      if (!status.valid) {
        form[input.name].error.value = status.message
      } else {
        form[input.name].error.value = null
      }
    })
  }

  return {
    form
  }
}

This works, too:

We are doing the exact same thing as before, but it’s just a bit more generic. Nothing too exciting.

Validating the Form

The last thing we need to do is add the form.valid property. This is a little challenging with our current setup: we would need to check every input’s error field every time any input was updated. We could do another loop after each input and check the error property on each input.

Another simple approach is to just define another reactive property to keep track of this for us. This makes this much more simple - however, it is not without it’s downsides: we are introducing duplication, and a second source of truth. This is a trade-off I’m happy to make - if it turns out there are some edge cases, I may reconsider this approach. For now, let’s see it in action. This is the final code for this example.

export const useForm = (inputs: FormInput[]) => {
  const form = {
    valid: ref(false),
  }

  // 1. Create a reactive object with [name]: boolean
  let errors = inputs.reduce<Record<string, boolean>>((acc, curr) => {
    acc[curr.name] = false
    return acc
  }, {})
  errors = reactive(errors)

  for (const input of inputs) {
    errors[input.name] = false
    form[input.name] = {
      ref: ref(input.value),
      error: ref<string | null>(null)
    }

    watch(form[input.name].ref, val => {
      const status = validate(val, input.rules)
      if (!status.valid) {
        form[input.name].error.value = status.message
        // Update errors
        errors[input.name] = false
      } else {
        form[input.name].error.value = null
        // Update errors
        errors[input.name] = true
      }
    })
  }

  // Update form.valid if all fields are valid.
  watch(errors, val => {
    form.valid.value = Object.values(val).every(valid => valid === true)
  })

  return {
    form
  }
}

We start by creating a reactive object with a key for each input, and setting them to false by default. Whenever the errors object changes, we check if the form is now valid. This is pretty efficient - watch only runs when a transition from true -> false happens, of vice versa, not on every input.

Conclusion

We build a little validation library in a framework agnostic manner, then we integrated it with our framework of choice (in this case Vue). Integrating with React, Angular, Svelte or any other framework would be just as trivial. We also saw the benefits of framework agnostic design - the tests run fast, and are easy to write.

You can find the full source code for this article here.


Register your email to get occasional emails about new screencasts and courses!
Thanks for registering!