Chapter 10 of 11

Vue 3 tutorial: forms and two-way binding with v-model

verified on 2 September 2026 · 5 min

Quick answer

v-model creates a two-way binding between a form field and a piece of data. The .number modifier casts the input to a number, .trim strips the whitespace, and @submit.prevent stops the page reloading when the form is sent.

By the end of this chapter, you will know how to build a complete form, validate it and hand its result to the parent.

v-bind ties data to the template, in one direction only. A form needs the other direction too: whatever the user types has to make its way back into the state. That is the job of v-model.

The form CSS

Add these rules to src/assets/boutique.css:

src/assets/boutique.css
.review-form { display: flex; flex-direction: column; width: 425px; padding: 20px; border: 2px solid #d8d8d8; background: #fff; }
.review-container { width: 425px; padding: 20px; border: 2px solid #d8d8d8; background: #fff; }
.review-error { color: #b91c1c; }

The form component

Create src/components/ReviewForm.vue. Every field is wired to a ref with v-model:

src/components/ReviewForm.vue
<script setup>
import { ref } from 'vue'

const name = ref('')
const review = ref('')
const rating = ref(null)
</script>

<template>
  <form class="review-form">
    <h3>Laisser un avis</h3>

    <label for="name">Nom</label>
    <input id="name" v-model="name">

    <label for="review">Avis</label>
    <textarea id="review" v-model="review"></textarea>

    <label for="rating">Note</label>
    <select id="rating" v-model.number="rating">
      <option disabled :value="null">Choisir une note</option>
      <option>5</option>
      <option>4</option>
      <option>3</option>
      <option>2</option>
      <option>1</option>
    </select>

    <button class="button" type="submit">Envoyer</button>
  </form>
</template>

Type in the “Nom” field and watch name in the Vue devtools: the value follows every keystroke.

What v-model actually does

v-model is not magic, it is a shorthand. On an <input>, these two lines are equivalent:

html
<input v-model="name">

<input :value="name" @input="name = $event.target.value">

A binding going down, a listener coming back up. Vue picks the attribute and the event to match the element: value and input for a text field, checked and change for a checkbox, value and change for a select.

The modifiers

Three suffixes save you repetitive code:

  • .number casts the input to a number. Without it, a <select> and an <input type="number"> both return a string: rating would hold "5", and a === 5 test would fail.
  • .trim strips the leading and trailing whitespace.
  • .lazy syncs on the change event rather than on every keystroke.
html
<input id="name" v-model.trim="name">
<select id="rating" v-model.number="rating">

Submitting

An HTML form reloads the page when it is sent. The .prevent modifier from chapter 5 stops it:

src/components/ReviewForm.vue
<form class="review-form" @submit.prevent="onSubmit">

The function validates, emits the review to the parent, then clears the fields:

src/components/ReviewForm.vue
<script setup>
import { ref } from 'vue'

const emit = defineEmits(['review-submitted'])

const name = ref('')
const review = ref('')
const rating = ref(null)
const error = ref('')

function onSubmit() {
  if (name.value === '' || review.value === '' || rating.value === null) {
    error.value = 'Avis incomplet. Merci de remplir tous les champs.'
    return
  }

  emit('review-submitted', {
    name: name.value,
    review: review.value,
    rating: rating.value
  })

  name.value = ''
  review.value = ''
  rating.value = null
  error.value = ''
}
</script>

Render the error message in the template, just before the button:

src/components/ReviewForm.vue
<p v-if="error" class="review-error" role="alert">{{ error }}</p>
Why not alert()?

An alert() box blocks the page, is not read out by a screen reader at the right moment, and cannot be tested. A paragraph carrying role="alert" is announced by assistive technology as soon as it appears, and stays on screen while the visitor fixes the form.

Displaying the reviews

Create src/components/ReviewList.vue, which receives the list as a prop:

src/components/ReviewList.vue
<script setup>
defineProps({
  reviews: { type: Array, required: true }
})
</script>

<template>
  <div class="review-container">
    <h3>Avis</h3>
    <ul>
      <li v-for="(review, index) in reviews" :key="index">
        {{ review.name }} a mis {{ review.rating }} étoiles
        <br>
        « {{ review.review }} »
      </li>
    </ul>
  </div>
</template>

Putting it together

ProductDisplay.vue becomes the parent of both new components: it holds the reviews and listens to the form, using the event emitting from the previous chapter.

src/components/ProductDisplay.vue
<script setup>
import ReviewForm from './ReviewForm.vue'
import ReviewList from './ReviewList.vue'

const reviews = ref([])

function addReview(review) {
  reviews.value.push(review)
}
</script>

<template>
  <div class="product-display">
    <div class="product-container">
      <!-- la fiche produit, inchangée -->
    </div>

    <ReviewList v-if="reviews.length" :reviews="reviews" />
    <ReviewForm @review-submitted="addReview" />
  </div>
</template>

The v-if="reviews.length" keeps an empty box off the page until a review has been left.

Send the form empty: the error message appears. Fill it in: the review joins the list and the fields clear.

The reviews disappear on reload, since they only live in memory. Keeping them means a database: see Firebase with Vue.

v-model on a component

v-model works on your own components too. Since Vue 3.4, the defineModel macro makes it immediate:

src/components/ChampTexte.vue
<script setup>
const valeur = defineModel()
</script>

<template>
  <input :value="valeur" @input="valeur = $event.target.value">
</template>

The parent then uses it like a native field:

html
<ChampTexte v-model="name" />

The application is finished

It shows a product and its variants, reacts to hover, handles stock, fills a cart and accepts validated reviews. The next chapter adds the finishing touch: enter and leave animations.

In the Options API

v-model and its modifiers are written exactly the same way. The fields are declared in data(), the validation in methods, and the emit goes through this.$emit.

ExerciseAdd an “I recommend this product” checkbox bound with v-model to a boolean, pass it along in the emitted review and display it in the list.

Common errors

Forgetting .number on a rating A <select> and an <input type="number"> return a string. Without .number, a === 5 test fails and arithmetic concatenates instead of adding.
Forgetting .prevent on the form The page reloads when the form is sent and the state is lost. Write @submit.prevent="onSubmit".
Validating with alert() The box blocks the page and is not announced properly by screen readers. Show a message with role="alert".
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.