Skip to content
TutorialBeginner

Forms and inputs: v-model modifiers, checkboxes and selects

v-model looks the same on every form element, but it reads a different property on each one and writes whatever the browser hands it. Here is what you get from each input, and how to get the type you meant.

vuejs.school team9 Jun 20265 min readVue 3.5

v-model keeps a piece of state and a form field in sync: type in the field and the state changes, change the state and the field updates. What it writes depends on the element. A checkbox can give you a boolean or a list. A select can give you a string, a number or a whole object. And a field that shows a number can hand you text, which is how 2 adults and 0 children add up to '20'.

Once you know what each field writes, you can give its state the type you want from the start. Each example is one component in src/components/, written for Vue 3.5 with <script setup lang="ts">.

Text fields and textareas

On a text <input> or a <textarea>, v-model reads the element's value and updates your state on every input event, which means on every keystroke:

src/components/Greeting.vue
<script setup lang="ts">
import { ref } from 'vue'

const name = ref('')
const notes = ref('')
</script>

<template>
  <label for="name">Name</label>
  <input id="name" v-model="name">
  <p>Hello, {{ name || 'stranger' }}.</p>

  <label for="notes">Notes</label>
  <textarea id="notes" v-model="notes"></textarea>
</template>

Two rules from the Vue guide save confusion:

  • v-model ignores a starting value, checked or selected attribute written in the template. Your state is the source of truth, so put the starting value in the ref. (On radio buttons, options and checkboxes bound to a list, value still matters: it is what the field writes when chosen.)
  • {{ notes }} between <textarea> tags only works one way. It shows the text, but nothing the user types reaches notes. Use v-model.

Checkboxes: one boolean or a list

A single checkbox bound to a boolean gives you true or false. Several checkboxes bound to the same array give you a list of the values that are ticked. If you want a single checkbox to produce something other than a boolean, true-value and false-value set what it writes:

src/components/Preferences.vue
<script setup lang="ts">
import { ref } from 'vue'

const agree = ref(false)
const topics = ref<string[]>([])
const newsletter = ref<'weekly' | 'never'>('never')
</script>

<template>
  <label><input v-model="agree" type="checkbox"> I accept the terms</label>

  <fieldset>
    <legend>Topics you want covered</legend>
    <label><input v-model="topics" type="checkbox" value="components"> Components</label>
    <label><input v-model="topics" type="checkbox" value="routing"> Routing</label>
    <label><input v-model="topics" type="checkbox" value="testing"> Testing</label>
  </fieldset>

  <label>
    <input v-model="newsletter" type="checkbox" true-value="weekly" false-value="never">
    Send me the weekly newsletter
  </label>
</template>

Tick Testing, then Routing, and topics is ['testing', 'routing']: the order you clicked, not the page order. Ticking the last box sets newsletter to 'weekly', and unticking it sets 'never'.

true-value and false-value are Vue attributes that work only with v-model. They change your state, not the form data: a classic form post would still send the box's own value, or nothing when it is unticked.

The list version works only when the starting value is an array (or a Set). Start with a string instead and Vue falls back to the single-checkbox behaviour:

src/components/Preferences.vueBreaks
<script setup lang="ts">
import { ref } from 'vue'

const topics = ref('')
</script>

<template>
  <label><input v-model="topics" type="checkbox" value="components"> Components</label>
  <label><input v-model="topics" type="checkbox" value="routing"> Routing</label>
</template>

Click Components and topics becomes true. Every box bound to it now compares itself to true, so they all tick at once. TypeScript does not catch it either: ref('') says string, and vue-tsc accepts the binding. The fix is one line:

src/components/Preferences.vueWorks
const topics = ref<string[]>([])

Radio buttons and selects: strings, objects and arrays

Radio buttons and selects both write the value of what the user picked. This component has a radio group, a select of objects and a multiple select:

src/components/SessionPicker.vue
<script setup lang="ts">
import { ref } from 'vue'

type Session = { id: string; label: string; starts: string }

const morning: Session = { id: 'am', label: 'Morning', starts: '10:00' }
const afternoon: Session = { id: 'pm', label: 'Afternoon', starts: '14:00' }
const sessions = [morning, afternoon]

const size = ref<'S' | 'M' | 'L'>('M')
const session = ref(morning)
const diets = ref<string[]>([])
</script>

<template>
  <fieldset>
    <legend>T-shirt size</legend>
    <label><input v-model="size" type="radio" name="size" value="S"> Small</label>
    <label><input v-model="size" type="radio" name="size" value="M"> Medium</label>
    <label><input v-model="size" type="radio" name="size" value="L"> Large</label>
  </fieldset>

  <label for="session">Session</label>
  <select id="session" v-model="session">
    <option v-for="s in sessions" :key="s.id" :value="s">{{ s.label }} ({{ s.starts }})</option>
  </select>

  <label for="diets">Dietary needs</label>
  <select id="diets" v-model="diets" multiple>
    <option value="vegetarian">Vegetarian</option>
    <option value="vegan">Vegan</option>
    <option value="gluten-free">Gluten-free</option>
  </select>
</template>
  • The radio buttons share one piece of state, and each writes its own value when chosen: pick Large and size is 'L'. Give them the same name as well, so the browser treats them as one group for the keyboard.
  • The session select binds each option with :value="s", so session holds the whole object, not a string id. That saves a lookup later. Vue compares option values by their contents, so a fresh object with the same fields still selects the right option.
  • The multiple select writes an array of the chosen values, so diets starts as one. Bind it to anything but an array or a Set and Vue warns in the console: <select multiple v-model> expects an Array or Set value for its binding, but got String.

If a single select starts with a value that matches no option, it shows nothing selected. The guide recommends a first option such as <option disabled value="">Please select one</option>, because iOS does not fire a change event when the user picks the first item from that state.

Modifiers: .lazy, .trim and .number

Modifiers go after a dot and change how v-model copies the value:

src/components/SignupFields.vue
<script setup lang="ts">
import { ref } from 'vue'

const nickname = ref('')
const email = ref('')
const age = ref<number | string>('')
</script>

<template>
  <label for="nickname">Nickname</label>
  <input id="nickname" v-model.lazy="nickname">
  <label for="email">Email</label>
  <input id="email" v-model.trim="email" type="email">
  <label for="age">Age</label>
  <input id="age" v-model.number="age" inputmode="numeric">
</template>
  • .lazy updates the state on change instead of input: when the field loses focus or the user presses Enter, not on every key. Use it when each update triggers something slow, such as a search request or a heavy check.
  • .trim strips spaces from both ends. When the field loses focus, Vue also writes the trimmed text back into it, so the user sees what was kept.
  • .number converts the text with parseFloat(). If that fails, you get the original string. An empty field gives '', not 0. So age is typed number | string: inputmode="numeric" only asks phones for a number keypad, and typed letters stay a string.

Inputs with type="number" get .number automatically. Modifiers combine, so v-model.lazy.trim is fine.

Fix numbers that arrive as strings

Text fields and options hand Vue text unless you say otherwise. Here a booking form counts people from two selects:

src/components/Guests.vueBreaks
<script setup lang="ts">
import { computed, ref } from 'vue'

const adults = ref(1)
const children = ref(0)
const people = computed(() => adults.value + children.value)
</script>

<template>
  <label for="adults">Adults</label>
  <select id="adults" v-model="adults">
    <option v-for="n in 4" :key="n">{{ n }}</option>
  </select>
  <label for="children">Children</label>
  <select id="children" v-model="children">
    <option v-for="n in 4" :key="n">{{ n - 1 }}</option>
  </select>
  <p>Total guests: {{ people }}</p>
</template>

The page starts with a total of 1, which looks right. Choose 2 adults and the total becomes 20. An option without a value attribute uses its text, and text is a string, so adults becomes '2', and '2' + 0 joins the two into '20'. Pick one child and you get '21'. As before, ref(1) claims to be a number, and vue-tsc does not flag the string that v-model writes into it.

Keep the script and bind :value in the template, so each option carries a real number:

src/components/Guests.vueWorks
<template>
  <label for="adults">Adults</label>
  <select id="adults" v-model="adults">
    <option v-for="n in 4" :key="n" :value="n">{{ n }}</option>
  </select>
  <label for="children">Children</label>
  <select id="children" v-model="children">
    <option v-for="n in 4" :key="n" :value="n - 1">{{ n - 1 }}</option>
  </select>
  <p>Total guests: {{ people }}</p>
</template>

Now 2 adults and 1 child make 3. v-model.number on each select would also work, but binding :value is the better habit, because it is the same move that lets an option hold an object.

A type="number" input writes a number or '': the browser reports a cleared field, or anything that is not a valid number, as empty. Type it as ref<number | ''>(1) and check before you use it, as the form below does.

Submit the form without reloading the page

A <form> with a submit button already handles the Enter key and runs the browser's own checks for required and type="email". Left alone, the browser then loads the form's action address, or reloads the page when there is none. @submit.prevent calls event.preventDefault() for you, so your handler runs and the page stays put:

src/components/BookingForm.vue
<script setup lang="ts">
import { ref } from 'vue'

const name = ref('')
const email = ref('')
const tickets = ref<number | ''>(1)
const sent = ref(false)

async function submit() {
  if (tickets.value === '' || tickets.value < 1) return
  const res = await fetch('/api/bookings', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ name: name.value, email: email.value, tickets: tickets.value }),
  })
  sent.value = res.ok
}
</script>

<template>
  <form @submit.prevent="submit">
    <label for="name">Name</label>
    <input id="name" v-model.trim="name" required>
    <label for="email">Email</label>
    <input id="email" v-model.trim="email" type="email" required>
    <label for="tickets">Tickets</label>
    <input id="tickets" v-model="tickets" type="number" min="1" max="6" required>
    <button type="submit">Book</button>
    <p v-if="sent" role="status">Booked. Check your inbox.</p>
  </form>
</template>

Because the browser validates required, type="email" and min before the submit event fires, submit() only runs with a filled-in form. The check on tickets stays anyway: its type says it can be '', and TypeScript refuses tickets.value < 1 until that case is ruled out.

sent turns on only when your endpoint answers with a success status: fetch does not throw on a 404 or a 500.

Check it stuck3 questions from the Forms and inputs quiz.
Practice now
Was this helpful?

Keep reading