Clean, Scalable Forms with Vue Composition API
Anthony Gore | March 31st, 2020 | 8 min read
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.
Table of contents:
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 I 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>
About Anthony Gore
If you enjoyed this article, show your support by buying me a coffee. You might also enjoy taking one of my online courses!
Click to load comments...