Reusable "Form Input" Component

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Our new goal is to avoid duplicating all of this markup for every form field by creating a nice, reusable component. Woo!

Creating the new Component

Inside the checkout/ directory, create a new form-input.vue file. For the template, go over and copy the entire field - including the div around it - delete, then paste.

<template>
<div class="form-group">
<label
for="customerName"
class="col-form-label"
>
Name:
</label>
<input
id="customerName"
v-model.trim="form.customerName"
type="text"
:class="{
'is-invalid': !isFieldValid('customerName'),
'form-control': true,
}"
>
<span
v-show="!isFieldValid('customerName')"
class="invalid-feedback"
>
{{ validationErrors.customerName }}
</span>
</div>
</template>
... lines 26 - 37

For the script, export default an object with a nice name, like FormInput. Finally, go grab the methods key from the other component, delete it and add it to the new component.

... lines 1 - 26
<script>
export default {
name: 'FormInput',
methods: {
isFieldValid(fieldName) {
return !(fieldName in this.validationErrors);
},
},
};
</script>

Okay: nothing is dynamic yet, but it's a start. Let's immediately try to use this inside of index.vue. Import FormInput from @/components/checkout/form-input, add a components key, and put this inside.

... lines 1 - 10
<script>
import FormInput from '@/components/checkout/form-input';
export default {
name: 'CheckoutForm',
components: {
FormInput,
},
... lines 19 - 31
};
</script>

Use it up in the template: <form-input />.

<template>
<div class="row p-3">
<div class="col-12">
<form>
<form-input />
</form>
</div>
</div>
</template>
... lines 10 - 34

When we move over to the browser and click to check out... we get a huge error. A bunch of things are still hardcoded in the new component and, most importantly, we have a v-model that's referencing a form data that doesn't exist! Time to make this component truly reusable.

Setting up the Props

To do that, we're going to need to pass several pieces of info into this component as props to replace the hardcoded parts.

Down in the component, add a props key. First, we need an id prop. This will be type: string and required: true:

... lines 1 - 26
<script>
export default {
name: 'FormInput',
props: {
id: {
type: String,
required: true,
},
... lines 35 - 46
},
... lines 48 - 52
};
</script>

Copy this. Another prop we need is label, which will also be a string...

... lines 1 - 26
<script>
export default {
name: 'FormInput',
props: {
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
... lines 39 - 46
},
... lines 48 - 52
};
</script>

and then errorMessage: This component will not determine whether or not its data is valid all on its own: it's just a dumb component that will render an input. Instead, its parent component will be responsible for executing validation and then telling this component whether or not it's valid. This will be also be a String, but instead of required: true, say default empty string:

... lines 1 - 26
<script>
export default {
name: 'FormInput',
props: {
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
errorMessage: {
type: String,
default: '',
},
... lines 43 - 46
},
... lines 48 - 52
};
</script>

The last prop we need is value - the value of the input. We could technically skip this... because the field will always start blank, but let's add it. Once again, set default to an empty string:

... lines 1 - 26
<script>
export default {
name: 'FormInput',
props: {
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
errorMessage: {
type: String,
default: '',
},
value: {
type: String,
default: '',
},
},
... lines 48 - 52
};
</script>

Ok! Up in the template, let's make things dynamic! for becomes :for="id", replace "Name" with {{ label }}, and :id="id". We won't be able to use v-model anymore because the data will be stored in the parent component. All we need to do, for now, is set the value attribute: :value="value".

<template>
<div class="form-group">
<label
:for="id"
class="col-form-label"
>
{{ label }}
</label>
<input
:id="id"
:value="value"
... lines 12 - 16
>
... lines 18 - 23
</div>
</template>
... lines 26 - 55

For this isFieldValid() method... hmm. We don't need a full method anymore: we're directly passed a simple errorMessage prop. Down in the component, change methods to computed and rename this to isValid() with no arguments. Inside, return not this.errorMessage.

... lines 1 - 26
<script>
export default {
name: 'FormInput',
... lines 30 - 47
computed: {
isValid() {
return !this.errorMessage;
},
},
};
</script>

Back up, this will simplify things: use not isValid in both places... then print {{ errorMessage }}.

<template>
<div class="form-group">
... lines 3 - 8
<input
... lines 10 - 12
:class="{
'is-invalid': !isValid,
'form-control': true,
}"
>
<span
v-show="!isValid"
class="invalid-feedback"
>
{{ errorMessage }}
</span>
</div>
</template>
... lines 26 - 55

Oh, and I don't really need to do this, but I'm going to add a :name="id" attribute. We don't need a name because the form won't actually submit: we're going to intercept that and send an AJAX call. But, this makes me feel better for some reason.

<template>
<div class="form-group">
... lines 3 - 8
<input
:id="id"
:name="id"
... lines 12 - 17
>
... lines 19 - 24
</div>
</template>
... lines 27 - 56

Anyways, this is good! This component is now re-usable. Back in index.vue, let's pass in the props! I'll drop the form-input onto multiple lines and pass id="customerName", :value="form.customerName", label="Name:" and :errorMessage="validationErrors.customerName".

<template>
<div class="row p-3">
<div class="col-12">
<form>
<form-input
id="customerName"
:value="form.customerName"
label="Name:"
:error-message="validationErrors.customerName"
/>
</form>
</div>
</div>
</template>
... lines 15 - 39

Let's check it out! Back at the checkout form... beautiful! We have the exact same thing as before.

Undefined Props and Default Values

Over on the Vue dev tools, on the FormInput component, notice that the errorMessage prop is set to empty quotes. In reality, we're passing validationErrors.customerName, which is undefined, because validationErrors is currently a empty object. When you pass an undefined value as a prop... and that prop has a default value, it uses that default, which is nice.

The only thing that doesn't work is that, when we type in the input, the data on CheckoutForm is not being updated: it's still empty quotes. No problem! We know how to fix this: we will emit an event from FormInput whenever the input changes, listen to that from CheckoutForm and update the data there.

But... hold on a second. If this were not a custom component - if this were just a normal, boring input - then we could use v-model to update the data when the input changes and be done.

My question is: could we still use v-model here... even though this is a custom component? The answer is... totally! Let's see how next.

Leave a comment!

This course is also built to work with Vue 3!

What JavaScript libraries does this tutorial use?

// package.json
{
    "devDependencies": {
        "@fortawesome/fontawesome-free": "^5.15.1", // 5.15.1
        "@symfony/webpack-encore": "^0.30.0", // 0.30.2
        "axios": "^0.19.2", // 0.19.2
        "bootstrap": "^4.4.1", // 4.5.3
        "core-js": "^3.0.0", // 3.6.5
        "eslint": "^6.7.2", // 6.8.0
        "eslint-config-airbnb-base": "^14.0.0", // 14.2.0
        "eslint-plugin-import": "^2.19.1", // 2.22.1
        "eslint-plugin-vue": "^6.0.1", // 6.2.2
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "sass": "^1.29.0", // 1.29.0
        "sass-loader": "^8.0.0", // 8.0.2
        "vue": "^2.6.11", // 2.6.12
        "vue-loader": "^15.9.1", // 15.9.4
        "vue-template-compiler": "^2.6.11", // 2.6.12
        "webpack-notifier": "^1.6.0" // 1.8.0
    }
}