DEV Community

Cover image for  Clean, Scalable Forms with Vue Composition API
Anthony Gore
Anthony Gore

Posted on • Updated on

Clean, Scalable Forms with Vue Composition API

Forms are one of the trickiest parts of frontend development and where you'll likely find a lot of messy code.

Component-based frameworks like Vue.js 2 have done a lot to improve the scalability of frontend code, but the problem of forms has persisted.

In this tutorial, I'll show you how the new Vue Composition API (coming to Vue 3) will make your form code much cleaner and more scalable.

Note: this article was originally posted here on the Vue.js Developers blog on 2020/03/30.

Why form code often sucks

The key design pattern of component-based frameworks like Vue is component composition. This pattern tells us to abstract the features of our app into isolated, single-purpose components that communicate state with props and events.

However, forms can't be abstracted very neatly under this pattern because the functionality and state of a form doesn't clearly belong to any one component and so separating it often causes as many problems as it solves.

Another important reason form code often sucks in Vue apps is that, up until Vue 2, Vue has not had a strong means of reusing code between components. This is important in forms as form inputs are often distinctly different but share many similarities in functionality.

The main method of code reuse offered by Vue 2 is mixins which many would argue are a blatant anti-pattern.

The Vue Composition API

The Composition API is a new way of defining components with Vue.js and will be a core feature of Vue 3. It's also available to use today in Vue 2 as a plugin.

This new API is designed to combat some of the issues I've mentioned (not just in forms but in any aspect of frontend app architecture).

If you're still new to the Composition API or aren't clear what it's for, I recommend you first read the docs and also another article I wrote, When To Use The New Vue Composition API (And When Not To).

The Composition API is not a replacement for the classic Vue API, but something you can use when it's called for. As you'll see in this article, creating clean and scalable form code is a perfect use case.

Adding the Composition API to a Vue 2 project

Since I'm writing this tutorial before Vue 3 has been released, let's add the Composition API to a Vue 2 project as a plugin.

We'll begin by creating a new Vue CLI project (just the bare features is all we need - no router, Vuex, etc) and install the Composition API plugin with NPM.

$ vue create composition-api-form
$ cd composition-api-form
$ npm i -S @vue/composition-api

Next, let's add the plugin to our Vue instance in main.js.

src/main.js

import Vue from "vue";
import App from "./App.vue";

import VueCompositionApi from "@vue/composition-api";
Vue.use(VueCompositionApi);

new Vue({
  render: h => h(App)
}).$mount('#app');

Creating form input components

To make this a simple example, we're going to create a form with just two inputs - a name, and and email. Let's create these as their own separate components.

$ touch src/components/InputName.vue
$ touch src/components/InputEmail.vue

Let's now set up the InputName component template in the typical way including an HTML input element with the v-model directive creating a two-way binding with the component.

src/components/InputName.vue

<template>
  <div>
    <label>
      Name
      <input type="text" v-model="input" name="name" />
    </label>
  </div>
</template>
<script>
export default {
  name: 'InputName'
}
</script>

Setting up the form

Let's leave the input for now and set up the form. You could create this as a separate component to make it reusable, but for simplicity of the tutorial, I'll just declare it in the App component template.

We'll add the novalidate attribute to let the browser know we'll be supplying custom validation. We'll also listen to the submit event of the form, prevent it from auto-submitting, and handle the event with an onSubmit method which we'll declare shortly.

We'll then add the InputName and InputEmail components and bind local state values name and email to them respectively.

src/App.vue

<template>
  <div id="app">
    <form novalidate @submit.prevent="onSubmit">
      <InputName v-model="name" />
      <InputEmail v-model="email" />
      <button type="submit">Submit</button>
    </form>
  </div>
</template>
<script>
import InputName from "@/components/InputName";
import InputEmail from "@/components/InputEmail";
export default {
  name: 'App',
  components: {
    InputName,
    InputEmail
  }
}
</script>

Let's now define the form functionality using the Composition API. We'll add a setup method to the component definition where we'll declare two state variables name and email using the ref method of the Composition API. This method will need to be imported from the Composition API package.

We'll then declare an onSubmit function to handle the form submission. I won't specify any functionality since it's irrelevant to this tutorial.

Finally, we need to return the two state variables and the method we've created from the setup function so that they're accessible to the component's template.

src/App.vue

...
import { ref } from "@vue/composition-api";

export default {
  name: "App",
  setup () {
    const name = ref("");
    const email = ref("");
    function onSubmit() {
      // submit to backend or whatever you like
      console.log(name.value, email.value);
    }
    return {
      name,
      email,
      onSubmit
    }
  },
  ...
}

Setting up the inputs

Next, we're going to define the functionality of the InputName component.

Since the parent form is using v-model with this component, it's important to declare a prop value which will be one half of the two-way binding.

Let's create a setup function. Props get passed into this method, as does a context object, giving us access to component instance methods. We can destructure this second argument and get the emit method. We'll need this to fulfill the other half of the v-model two-way binding i.e. to reactively emit new values of the input.

Before we get to that, let's declare a state variable input that will be bound to the input HTML element we declared in the template.

The value of this variable will be something we'll return from a to-be-defined composition function useInputValidator. This function will handle all the common validation logic.

We'll pass in the value prop to this method, and the second argument will be a callback function that returns the validated input value. Let's use this callback to emit this input as an event and fulfill the v-model contract.

src/components/InputName.vue

import useInputValidator from "@/features/useInputValidator";

export default {
  name: "InputName",
  props: {
    value: String
  },
  setup (props, { emit }) {
    const { input } = useInputValidator(
      props.value, 
      value => emit("input", value)
    );
    return {
      input
    }
  }
}

Input validator feature

Let's now create the useInputValidator composition function. To do so, we'll first create a features folder, and then create a module file for it.

$ mkdir src/features
$ touch src/features/useInputValidator.js

In the module file, we're going to export a function. We just saw it will need two arguments - the value prop received from the parent form, which we'll call startVal, and a callback method we'll call onValidate.

Remember that this function needs to return an input state variable, so let's go ahead and declare that, assigning a ref which is initialized with the value provided by the prop.

Before we return the input value from the function, let's watch its value and call the onValidate callback using the input as an argument.

src/features/useInputValidator.js

import { ref, watch } from "@vue/composition-api";

export default function (startVal, onValidate) {
  let input = ref(startVal);
  watch(input, value => { 
    onValidate(value);
  });
  return {
    input
  }
}

Adding validators

The next step is to add validator functions. For the InputName component, we just have one validation rule - a minLength ensuring the input is three characters or more. The yet-to-be-created InputEmail component will need an email validation.

We'll now create these validators in a JavaScript utility module validators.js in the src folder. In a real project, you'd probably use a third-party library instead.

I won't go through the validator functions in any great detail, but here are two important things to note:

  • These are functions that return functions. This architecture allows us to customize the validation by passing arguments that become part of the closure.
  • The returned function from each validator always returns either a string (the error message) or null in the case that there is no error.

src/validators.js

const minLength = min => {
  return input => input.length < min 
  ? `Value must be at least ${min} characters` 
  : null;
};

const isEmail = () => {
  const re = /\S+@\S+\.\S+/;
  return input => re.test(input)
  ? null
  : "Must be a valid email address";
}

export { minLength, isEmail };

Back in the composition function, we want the consuming component to define the validations it needs, so let's begin by adding another argument to the function profile validators which should be an array of validation functions.

Inside the input watcher, we'll now process the validation functions. Let's use the map method of the validators array, passing in the current value of the input to each validator method.

The return will be captured in a new state variable, errors, which we'll also return to the consuming component.

src/features/useInputValidator.js

export default function (startVal, validators, onValidate) {
  const input = ref(startVal);
  const errors = ref([]);
  watch(input, value => {
    errors.value = validators.map(validator => validator(value));
    onValidate(value);
  });
  return {
    input,
    errors
  }
}

Returning finally to the InputName component, we're now going to provide the required three arguments to the useInputValidator method. Remember, the second argument is now an array of validators, so let's declare an array in-place and pass in minLength which we'll get by importation from the validators file.

minLength is a factory function, so we call the function passing in the minimum length we want to specify.

We also get two objects returned from our composition function now - input and errors. Both of these will be returned from the setup method for availability in the component's render context.

src/components/InputName.vue

...
import { minLength } from "@/validators";

export default {
  ...
  setup (props, { emit }) {
    const { input, errors } = useInputValidator(
      props.value, 
      [ minLength(3) ],
      value => emit("input", value)
    );
    return {
      input,
      errors
    }
  }
}

That's the last of the functionality that we'll add to this component. Before we move on though, it's important to take a moment and appreciate how much more readable this code is than what you'd see if we were using mixins.

For one thing, we clearly see where our state variables are declared and modified without having to flick over to a separate mixin module file. For another thing, we don't need to be concerned about name clashes between our local variables and the composition function.

Displaying errors

Going to the template of our InputName component, we now have an array of potential errors to display. Let's delegate this to a presentation component called ErrorDisplay.

src/components/InputName.vue

<template>
  <div>
    <label>
      Name
      <input type="text" v-model="input" name="name" />
    </label>
    <ErrorDisplay :errors="errors" />
  </div>
</template>
<script>
...
import ErrorDisplay from "@/components/ErrorDisplay";

export default: {
  ...
  components: {
    ErrorDisplay
  }
}
</script>

The functionality of ErrorDisplay is too trivial to show here.

Reusing code

So that's the basic functionality of our Composition API-based form. The objective of this tutorial was to create clean and scalable form code and I want to prove to you that we've done this by finishing off with the definition of our second custom input, InputEmail.

If the objective of this tutorial has been met you should have no trouble understanding it without my commentary!

src/components/InputEmail

<template>
  <div>
    <label>
      Email
      <input type="email" v-model="input" name="email" />
    </label>
    <ErrorDisplay v-if="input" :errors="errors" />
  </div>
</template>
<script>
import useInputValidator from "@/features/useInputValidator";
import { isEmail } from "@/validators";
import ErrorDisplay from "./ErrorDisplay";

export default {
  name: "InputEmail",
  props: {
    value: String
  },
  setup (props, { emit }) {
    const { input, errors } = useInputValidator(
      props.value, 
      [ isEmail() ], 
      value => emit("input", value)
    );
    return {
      input,
      errors
    }
  },
  components: {
    ErrorDisplay
  }
}
</script>

Enjoy this article?

Get more articles like this in your inbox weekly with the Vue.js Developers Newsletter.

Click here to join!


Top comments (0)