v-model connects a form control’s displayed value and its reactive source. Different controls use different DOM properties: text inputs use value, checkboxes use checked values, and selects use selected options. The application still owns validation, submission state, and the server response.
v-model creates a two-way binding between a form input and reactive data. It's shorthand for :value="data" + @input="data = $event.target.value". Vue's v-model is much simpler than React's controlled components.
<template>
<form @submit.prevent="handleSubmit">
<!-- Text input -->
<input v-model="form.name" placeholder="Name" />
<!-- Email -->
<input v-model="form.email" type="email" placeholder="Email" />
<!-- Number with .number modifier -->
<input v-model.number="form.age" type="number" />
<!-- Textarea -->
<textarea v-model="form.bio" rows="4" />
<!-- Select -->
<select v-model="form.country">
<option value="">Select country</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="in">India</option>
</select>
<!-- Multi-select -->
<select v-model="form.skills" multiple>
<option v-for="skill in availableSkills" :key="skill" :value="skill">
{{ skill }}
</option>
</select>
<!-- Radio buttons -->
<label v-for="role in roles" :key="role">
<input type="radio" v-model="form.role" :value="role" />
{{ role }}
</label>
<!-- Checkbox (boolean) -->
<label>
<input type="checkbox" v-model="form.newsletter" />
Subscribe to newsletter
</label>
<!-- Checkbox (array) -->
<label v-for="tag in availableTags" :key="tag">
<input type="checkbox" v-model="form.tags" :value="tag" />
{{ tag }}
</label>
<button type="submit">Submit</button>
<pre>{{ JSON.stringify(form, null, 2) }}</pre>
</form>
</template>
<script setup>
import { reactive } from 'vue'
const form = reactive({
name: '', email: '', age: 0, bio: '',
country: '', skills: [], role: 'user',
newsletter: false, tags: []
})
const availableSkills = ['Vue', 'React', 'Angular', 'Node.js']
const roles = ['user', 'admin', 'moderator']
const availableTags = ['Frontend', 'Backend', 'DevOps', 'Design']
function handleSubmit() {
console.log('Form submitted:', form)
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="field">
<input v-model="form.email" type="email" placeholder="Email"
:class="{ 'input-error': errors.email }" @blur="validateField('email')" />
<span v-if="errors.email" class="error">{{ errors.email }}</span>
</div>
<div class="field">
<input v-model="form.password" type="password" placeholder="Password"
:class="{ 'input-error': errors.password }" @blur="validateField('password')" />
<span v-if="errors.password" class="error">{{ errors.password }}</span>
</div>
<button type="submit" :disabled="!isValid">Login</button>
</form>
</template>
<script setup>
import { reactive, computed } from 'vue'
const form = reactive({ email: '', password: '' })
const errors = reactive({ email: '', password: '' })
function validateField(field) {
if (field === 'email') {
if (!form.email) errors.email = 'Email is required'
else if (!/\S+@\S+\.\S+/.test(form.email)) errors.email = 'Invalid email'
else errors.email = ''
}
if (field === 'password') {
if (!form.password) errors.password = 'Password is required'
else if (form.password.length < 8) errors.password = 'Min 8 characters'
else errors.password = ''
}
}
const isValid = computed(() =>
form.email && form.password && !errors.email && !errors.password
)
function handleSubmit() {
validateField('email')
validateField('password')
if (isValid.value) alert('Login successful!')
}
</script>
Browser input values are strings by default. Use the .number modifier only when its empty and invalid-value behavior fits the field, or parse explicitly at validation time. .trim removes surrounding whitespace, while .lazy updates on change rather than each input event. Keep the raw draft when users need to correct incomplete values.
Associate labels with controls, expose field errors accessibly, and do not clear values after server validation fails. Disable duplicate submission while one write is active, but avoid disabling the whole form when only one dependent choice is loading. Treat server validation as authoritative.
v-model maps to different DOM properties and events by control type. Text inputs and textareas bind strings, a single checkbox normally binds a Boolean, checkbox groups bind an array, radio buttons bind the selected value, and select multiple binds an array. Initialize state with the same shape the control will produce.
Use .trim when surrounding whitespace is not meaningful, .number when a numeric conversion matches the domain, and .lazy when updates should occur on change instead of every input event. Number conversion can still produce an empty string, so validation must handle empty and invalid states explicitly.
<script setup>
import { reactive } from 'vue'
const form = reactive({ name: '', age: null, skills: [], plan: 'free' })
</script>
<template>
<input v-model.trim="form.name" name="name">
<input v-model.number="form.age" name="age" type="number">
<label><input v-model="form.skills" type="checkbox" value="vue"> Vue</label>
<select v-model="form.plan" name="plan">
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
</template>
Each field starts with the value shape produced by its control, avoiding Boolean, string, and array confusion.
Client validation improves feedback but does not establish trust; validate the same rules on the server. On submit, mark attempted fields, focus the first invalid control, prevent duplicate submissions while a request is pending, and preserve user input when the server rejects the request.
Every control needs a persistent label. Connect help and error text with aria-describedby, set aria-invalid only when invalid, and announce submission-level failures without moving focus unpredictably. Do not disable the submit button in a way that prevents keyboard users from discovering why submission is blocked.
Explore 500+ free tutorials across 20+ languages and frameworks.