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| Input | What the model holds |
|---|---|
| text, textarea, single select | A string |
| checkbox on its own | A boolean |
checkbox with value, model is an array | The array, with that value added or removed |
| radio group | The value of the checked input |
| multiple select | An array of the selected values |
number input without .number | A string, which is why comparisons surprise you |
v-modelis shorthand for a bound value plus an event handler:valueand@inputon native inputs,modelValueand@update:modelValueon 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
valueon 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 withv-model:currencyon the parent. .trimand.numberare applied before the value is written into state, so validation and the server both see the cleaned value..lazyis 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.modelValueis 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.
Related
Lifecycle hooks, watchers and cleanup Directives, lists and conditional rendering
Last refreshed 2026-09-18.