Forms and user input

Bind every input type with v-model, use modifiers, implement v-model on your own component, and keep validation and request volume under control.

v-model across every input type

<input v-model="form.email" type="email" />
<textarea v-model="form.notes" rows="4"></textarea>

<select v-model="form.plan">
  <option value="free">Free</option>
  <option value="pro">Pro</option>
</select>

<select v-model="form.topics" multiple>
  <option value="vue">Vue</option>
  <option value="vite">Vite</option>
</select>

<input type="checkbox" v-model="form.newsletter" /> Newsletter
<input type="checkbox" v-model="form.tags" value="vue" /> Vue
<input type="checkbox" v-model="form.tags" value="vite" /> Vite

<input type="radio" v-model="form.tier" value="a" /> A
<input type="radio" v-model="form.tier" value="b" /> B
InputWhat the model holds
text, textarea, single selectA string
checkbox on its ownA boolean
checkbox with value, model is an arrayThe array, with that value added or removed
radio groupThe value of the checked input
multiple selectAn array of the selected values
number input without .numberA string, which is why comparisons surprise you
  • v-model is shorthand for a bound value plus an event handler: value and @input on native inputs, modelValue and @update:modelValue on components.
  • Without .number, an input's value is always a string. '2' > '10' is true, which is the classic cause of a "broken" quantity field.
  • Give checkbox arrays an explicit value on each input; without it Vue pushes the boolean rather than the identifier you wanted.

Modifiers and a custom v-model

<input v-model.trim="form.name" />
<input v-model.number="form.qty" type="number" min="1" />
<input v-model.lazy="form.search" />         <!-- syncs on change, not per keystroke -->

<StarRating v-model="form.rating" />          <!-- default model -->
<PriceInput v-model:amount="form.amount" v-model:currency="form.currency" />
// StarRating.vue — script block
const rating = defineModel({ type: Number, default: 0 })

function set(value) {
  rating.value = value      // writes back to the parent through the emitted update
}
  • defineModel() is the compiler macro that implements the contract: it declares the prop, listens for the update event and gives you a writable ref.
  • The argument form declares a named model — defineModel('currency') pairs with v-model:currency on the parent.
  • .trim and .number are applied before the value is written into state, so validation and the server both see the cleaned value.
  • .lazy is what you want when per-keystroke work is expensive; without it the input fires on every character.
  • A component must never mutate an incoming prop directly. Setting the model ref is the write; assigning to props.modelValue is the mistake.
💡
Two-way binding hides the writer. When a value changes with no obvious cause, search the codebase for that name: a v-model, a watcher or a store action is doing the assignment, and that is where the bug is.

Validation and request volume

import { reactive, computed } from 'vue'

const form = reactive({ email: '', qty: 1, notes: '' })
const touched = reactive({})

const errors = computed(() => {
  const out = {}
  if (!form.email.includes('@')) out.email = 'Enter a valid email'
  if (form.qty < 1) out.qty = 'At least one'
  return out
})

const valid = computed(() => Object.keys(errors.value).length === 0)

function submit() {
  Object.keys(form).forEach(key => { touched[key] = true })
  if (!valid.value) return
  // send the payload
}
  • Show an error only after the field was touched or the form was submitted. Validation that fires on the first keystroke is noise, not help.
  • Keep the rules in a computed so the same logic drives the disabled state of the button and the messages under the fields.
  • Client validation is a convenience for the user, not a boundary. The server must validate the identical input again, because any client check can be skipped.
  • For a search field, debounce the value before it reaches the network and cancel the previous request when a newer one starts.
  • Hold the whole form in one reactive object so resetting is a single assignment rather than a field-by-field hunt.

FAQ

Why is my number field a string?
Native inputs hand you strings. Add the .number modifier, or convert explicitly on submit. Until then, arithmetic silently concatenates or compares lexicographically.
Should I use a form library?
For a handful of fields, a computed plus a touched map is less code than any dependency. Reach for a library when you need schema-driven rules, arrays of repeated fields, or validation shared with the backend.

Lifecycle hooks, watchers and cleanup Directives, lists and conditional rendering

Last refreshed 2026-09-18.