Pelumi Akintokun Frontend developer and technical writer who is passionate about the web. Creator of websites that tell stories.

Using v-model in Vue 3 to build complex forms

6 min read 1806

Vue Logo Over Color Blocks

In this article, we’ll introduce changes to the v-model directive in Vue 3. Then, we’ll step through a tutorial, demonstrating how to use multiple v-model bindings to simplify the process of building complex forms in Vue.

Jump ahead:

What is the v-model directive?

The Vue v-model directive enables two-way data binding on form input elements, such as the input element, textarea element, and select element on any Vue component.

It handles data updates in two ways:

  • when the value of the input changes, the v-model reflects the value onto the state inside the component
  • when the state of the component changes, the v-model reflects the changes onto the form input elements

The v-model directive uses distinct properties and emits different events for different input elements by default:

  • value property and input event for text and textarea elements
  • checked property and change event for checkboxes and radio buttons
  • value as a prop and change event for select fields

A simple input element in a custom component will look something like this:

<input
    type="text"
    :value="modelValue"
    @input="$emit(update:modelValue, $event.target.value)"
>

And its props will be defined like so:

props: {
        modelValue: {
            type: String,
            default: '',
            required: true
        }
}

In the parent component, the custom component would be used like this:

<CustomComponent v-model:modelValue="name" />

// or the shorthand

<CustomComponent v-model="name" />

In the custom component, the v-model directive assumes an internal property has been defined with the name modelValue and emits a single event called update:modelValue.

You are not limited to the default naming convention; you may use a different name of your choosing. Having descriptive names for our v-model bindings enables us to use less code when trying to read and define what properties are attached to the parent component.



Just be sure to be consistent when selecting naming properties. Here’s an example of a custom name, fullName, used for the modelValue property:

<input
    type="text"
    :value="fullName"
    @input="$emit(update:fullName, $event.target.value)"
>
props: {
        fullName: {
            type: String,
            default: '',
            required: true
        }
}
<CustomComponent v-model:fullName="fullName" />

// or the shorthand

<CustomComponent v-model="fullName" />

How does v-model handle data binding?

The v-model directive has three modifiers that can be used for data binding: .lazy, .number, and .trim. Let’s take a closer look.

.lazy

By default, v-model syncs with the state of the Vue instance after each input event is emitted. But with the .lazy modifier, the v-model allows the sync to occur after each change event.

Here’s an example showing use of the .lazy modifier:

<input v-model.lazy="message" />

.number

The .number modifier, on the other hand, allows us to automatically convert a user entry to a number. An HTML input’s default value is always a string, so this modifier can be super helpful. If the value can’t be parsed into a number, the original value is returned.

Here’s an example showing use of the .number modifier:

<input v-model.number="numPapayas" type="number" />

.trim

The .trim modifier, as the name suggests, automatically trims whitespace from the user input.

Here’s an example showing use of the .trim modifier:


More great articles from LogRocket:


<input v-model.trim="message" />

How is the v-model in Vue.js 3 different from Vue.js 2?

If you’re familiar with using the v-model directive in Vue 2, you understand how complex it was with regard to creating forms.

In Vue 2 we were only allowed to use one v-model per component. To support two-way data binding in complex components, a full-blown payload had to be utilized on the v-model.

The component would handle the state of all the input elements and a single payload object would be generated to represent the state of the component. An event with the attached payload would then be emitted to the parent component.

This method created issues when it came to creating Vue UI libraries because it wasn’t always clear what was included in the payload. Developers had no choice but to loop through the payload object to ascertain what properties were included.

Fortunately, Vue 3 provides developers with more flexibility and power when it comes to building custom components that support two-way data binding. In Vue 3, we’re allowed as many v-model directives as we need. This can be quite convenient, as we’ll demonstrate later in this article.

Multiple v-model directive bindings tutorial

Let’s see how we can use multiple v-model directive bindings to simplify a complex Vue form.

For our example, we’ll use a checkout form that lists the user’s first name, last name, and email address, followed by some fields related to billing and delivery.

Checkout Form

Creating the reusable component

The billing and delivery sections include the street name, street number, city, and postcode.

But, since a user’s billing and delivery address are often the same, let’s create a reusable address component for the form.

First, we’ll set up the Vue app using the following command:

vue create <project-name>

Then, we’ll create a reusable component, AddressFieldGroup.vue, inside a components folder within our src folder.

This reusable component will be imported into our App.vue file. With the v-model, this reusable component will be bound to a custom component in the App.vue file.

Let’s take a closer look at the reusable component, AddressFieldGroup.vue:

AddressFieldGroup.vue
<template>
  <section class="address">
    <h2>{{ label }}</h2>
    <div class="address__field">
      <label for="streetName">Street name</label>
      <input
        type="text"
        id="streetName"
        :value="streetName"
        @input="$emit('update:streetName', $event.target.value)"
        required
      />
    </div>
    <div class="address__field">
      <label for="streetNumber">Street number</label>
      <input
        type="text"
        id="streetNumber"
        :value="streetNumber"
        @input="$emit('update:streetNumber', $event.target.value)"
        required
      />
    </div>
    <div class="address__field">
      <label for="city">City</label>
      <input
        type="text"
        id="city"
        :value="city"
        @input="$emit('update:city', $event.target.value)"
        required
      />
    </div>
    <div class="address__field">
      <label for="postcode">Postcode</label>
      <input
        type="text"
        id="postcode"
        :value="postcode"
        @input="$emit('update:postcode', $event.target.value)"
        required
      />
    </div>
  </section>
</template>

<script>
export default {
  name: "AddressFieldGroup",
  props: {
    label: {
      type: String,
      default: "",
    },
    streetName: {
      type: String,
      default: "",
    },
    streetNumber: {
      type: String,
      default: "",
    },
    city: {
      type: String,
      default: "",
    },
    postcode: {
      type: String,
      default: "",
    },
  },
};
</script>

In the above code, the section element with class name address is reused (as we’ll see a little later in this article) to create the Billing Address and Delivery Address in the parent component.

The label prop gives each address section its relevant name and four input fields: streetName, streetNumber, city, and postcode. The props for each input field along with the label are defined in the script tag.

The label prop will be passed from the custom component, AddressFieldGroup, to its parent component in the App.vue file in order to provide each address group with a unique label or name (e.g., Billing Address or Delivery Address).

Creating the CheckoutForm

Now, we’ll create the Checkout Form inside our App.vue file and import the AddressFieldGroup.vue into the App.vue file as well:

App.vue
<template>
  <div class="app">
    <form @submit.prevent="handleSubmit" class="checkout-form">
      <h1>Checkout Form</h1>
      <div class="address__field">
        <label for="firstName">First name</label>
        <input type="text" id="firstName" v-model="form.firstName" required />
      </div>
      <div class="address__field">
        <label for="lastName">Last name</label>
        <input type="text" id="lastName" v-model="form.lastName" required />
      </div>
      <div class="address__field">
        <label for="email">Email</label>
        <input type="email" id="email" v-model="form.email" required />
      </div>
      <AddressFieldGroup
        label="Billing Address"
        v-model:streetName="form.billingAddress.streetName"
        v-model:streetNumber="form.billingAddress.streetNumber"
        v-model:city="form.billingAddress.city"
        v-model:postcode="form.billingAddress.postcode"
      />
      <AddressFieldGroup
        label="Delivery Address"
        v-model:streetName="form.deliveryAddress.streetName"
        v-model:streetNumber="form.deliveryAddress.streetNumber"
        v-model:city="form.deliveryAddress.city"
        v-model:postcode="form.deliveryAddress.postcode"
      />
      <div class="address__field">
        <button type="submit">Submit</button>
      </div>
    </form>
  </div>
</template>

<script>
import AddressFieldGroup from "./components/AddressFieldGroup";
import { reactive } from "vue";

export default {
  name: "CheckoutForm",
  components: {
    AddressFieldGroup: AddressFieldGroup,
  },
  methods: {
    handleSubmit() {
      alert("form submitted");
    },
  },
  setup() {
    const form = reactive({
      firstName: "",
      lastName: "",
      email: "",
      billingAddress: {
        streetName: "",
        streetNumber: "",
        city: "",
        postcode: "",
      },
      deliveryAddress: {
        streetName: "",
        streetNumber: "",
        city: "",
        postcode: "",
      },
    });

    return {
      form,
    };
  },
};
</script>

<style lang="scss">
.app {
  font-family: Arial, Helvetica, sans-serif;
  color: #434141;
  text-align: center;
}
.checkout-form {
  margin: 5px auto;
  padding: 10px;
  max-width: 500px;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.address__field {
  padding-bottom: 10px;
  width: 250px;
  text-align: left;
}
label {
  display: block;
  font-weight: bold;
}

input {
  padding: 10px;
  width: 230px;
  border: 1px solid #fff;
  border-radius: 5px;
  outline: 0;
  background: #f8edcf;
}

button {
  margin-top: 30px;
  padding: 10px;
  width: 250px;
  color: #f8edcf;
  border: 1px solid #fff;
  border-radius: 5px;
  outline: 0;
  background: #434141;
}
</style>

In the above code, we’ve created a CheckoutForm that contains three input fields: firstName, lastName, and email. We’ve also embedded the reusable AddressFieldGroup component twice in the form and used it to represent both the user’s Billing Address and Delivery Address.

We used the v-model:{property-name} format to bind every property on both custom AddressFieldGroup components.

In addition to the v-model shorthand syntax, this code is also shorter, simpler, and easier to read. This enables us to quickly decipher and decode the properties that are being passed between the parent component and the custom component (in this case, the reusable AddressFieldGroup component).

We also defined all properties in the CheckoutForm, including the properties of both addresses. We saved the properties inside a reactive object called form, returned its value to the component, and used it to set the bindings on the CheckoutForm.

Conclusion

In this article, we’ve explored the v-model directive, identified what Vue modifiers may be used with it, and demonstrated how to use multiple v-model bindings on Vue components to simplify the creation of complex Vue forms.

v-model gives us the flexibility to add multiple v-model directives on a single component instance and the modelValue can also be renamed according to our preference.

To view and play around with the example used in this article, check out the source code on CodeSandbox. Happy Vueing!

Experience your Vue apps exactly how a user does

Debugging Vue.js applications can be difficult, especially when there are dozens, if not hundreds of mutations during a user session. If you’re interested in monitoring and tracking Vue mutations for all of your users in production, try LogRocket. https://logrocket.com/signup/

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens in your Vue apps including network requests, JavaScript errors, performance problems, and much more. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred.

The LogRocket Vuex plugin logs Vuex mutations to the LogRocket console, giving you context around what led to an error, and what state the application was in when an issue occurred.

Modernize how you debug your Vue apps - .

Pelumi Akintokun Frontend developer and technical writer who is passionate about the web. Creator of websites that tell stories.

Leave a Reply