On this page
Forms and v-model: bindings, modifiers, and validation
Forms in Vue
HTML forms are the primary input mechanism for web applications. Vue's v-model directive makes form handling declarative and reactive — you bind form element values directly to reactive state and Vue keeps everything in sync automatically.
This lesson covers the full spectrum: simple v-model usage, all input types, custom form components, modifiers, and practical validation patterns.
v-model deep dive
v-model is syntactic sugar that expands to a value binding plus an event listener. The exact expansion depends on the input type:
| Input type | Bound property | Trigger event |
|---|---|---|
text, email, password, url |
value |
input |
checkbox |
checked |
change |
radio |
checked |
change |
select |
value |
change |
textarea |
value |
input |
<CustomComponent> |
modelValue prop |
update:modelValue |
Text inputs
<script setup lang="ts">
import { ref } from 'vue'
const username = ref('')
</script>
<template>
<input v-model="username" type="text" placeholder="Username" />
<p>Hello, {{ username }}</p>
</template>v-model modifiers
Vue provides three built-in modifiers:
<!-- .trim: strip leading/trailing whitespace automatically -->
<input v-model.trim="name" />
<!-- .number: convert to Number type (still returns string if NaN) -->
<input v-model.number="age" type="number" />
<!-- .lazy: sync on 'change' instead of 'input' (less frequent updates) -->
<input v-model.lazy="bio" />Checkboxes
A single checkbox binds to a boolean:
const agreed = ref(false)
<input type="checkbox" v-model="agreed" />Multiple checkboxes sharing the same v-model bind to a string[]:
const selectedFruits = ref<string[]>([])
<label><input type="checkbox" v-model="selectedFruits" value="apple" /> Apple</label>
<label><input type="checkbox" v-model="selectedFruits" value="banana" /> Banana</label>
<label><input type="checkbox" v-model="selectedFruits" value="cherry" /> Cherry</label>
<p>Selected: {{ selectedFruits.join(', ') }}</p>true-value and false-value
Customize the bound values for a single checkbox:
<input
type="checkbox"
v-model="status"
true-value="active"
false-value="inactive"
/>
<!-- status is 'active' when checked, 'inactive' when unchecked -->Radio buttons
Radio buttons bind a string (or whatever value attribute is set to):
const priority = ref<'low' | 'medium' | 'high'>('low')
<label><input type="radio" v-model="priority" value="low" /> Low</label>
<label><input type="radio" v-model="priority" value="medium" /> Medium</label>
<label><input type="radio" v-model="priority" value="high" /> High</label>Select boxes
const city = ref('')
const cities = ['New York', 'London', 'Tokyo', 'Sydney']
<select v-model="city">
<option value="" disabled>Choose a city</option>
<option v-for="c in cities" :key="c" :value="c">{{ c }}</option>
</select>For multi-select, use multiple and bind to an array:
const selectedCities = ref<string[]>([])
<select v-model="selectedCities" multiple size="4">
<option v-for="c in cities" :key="c" :value="c">{{ c }}</option>
</select>Bind objects as option values (not just strings):
interface Country { code: string; name: string }
const country = ref<Country | null>(null)
const countries: Country[] = [
{ code: 'US', name: 'United States' },
{ code: 'GB', name: 'United Kingdom' },
]
<select v-model="country">
<option :value="null" disabled>Select country</option>
<option v-for="c in countries" :key="c.code" :value="c">{{ c.name }}</option>
</select>
<p>Selected code: {{ country?.code }}</p>Custom form components with defineModel
Building reusable form components is simple with defineModel():
<!-- components/AppInput.vue -->
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
const model = defineModel<string>()
defineProps<{
label: string
error?: string
id: string
}>()
</script>
<template>
<div class="field">
<label :for="id">{{ label }}</label>
<input
:id="id"
v-bind="$attrs"
:value="model"
:aria-invalid="!!error"
@input="model = ($event.target as HTMLInputElement).value"
/>
<p v-if="error" role="alert" class="error">{{ error }}</p>
</div>
</template>Usage:
<AppInput
id="email"
v-model="form.email"
label="Email address"
type="email"
:error="errors.email"
/>Form validation patterns
Inline validation with computed
const isEmailValid = computed(() =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())
)Validation on blur
Validate each field when the user leaves it:
const touched = reactive<Record<string, boolean>>({})
const errors = reactive<Record<string, string>>({})
function validate(field: string) {
touched[field] = true
errors[field] = '' // clear
if (field === 'email' && !isEmailValid.value) {
errors[field] = 'Enter a valid email address'
}
// ... other fields
}<input v-model="form.email" type="email" @blur="validate('email')" />
<p v-if="touched.email && errors.email" role="alert">{{ errors.email }}</p>Schema validation with Zod
For complex forms, define a Zod schema and validate against it:
import { z } from 'zod'
const schema = z.object({
name: z.string().min(2, 'At least 2 characters'),
email: z.string().email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
age: z.number().min(18, 'Must be 18 or older'),
})
type FormData = z.infer<typeof schema>
function validateForm(data: unknown) {
const result = schema.safeParse(data)
if (!result.success) {
return result.error.flatten().fieldErrors
}
return null
}Accessibility in forms
Well-structured forms are essential for screen reader users:
<div class="field">
<!-- Always link label to input with for/id -->
<label for="username">Username</label>
<input
id="username"
v-model="form.username"
type="text"
autocomplete="username"
:aria-invalid="!!errors.username"
:aria-describedby="errors.username ? 'username-error' : undefined"
required
/>
<!-- Use role="alert" so screen readers announce errors immediately -->
<p v-if="errors.username" id="username-error" role="alert" class="error">
{{ errors.username }}
</p>
</div>Key accessibility requirements:
- Every
<input>must have a linked<label>(viafor/idoraria-label) - Error messages must be associated via
aria-describedby - Invalid inputs must have
aria-invalid="true" - Errors should be announced via
role="alert" - Never use color alone to indicate errors — pair with an icon or text
Practice
- Multi-step form: Build a 3-step form with a progress indicator. Each step is a different component. The form data lives in a Pinia store so it persists across steps. Validate each step before allowing the user to advance.
- Custom Select component: Create a
<AppSelect>component withdefineModel()that accepts anoptionsprop and renders a styled<select>. Make sure it forwards all native<select>attributes viav-bind="$attrs". - Password strength indicator: Add a computed property that scores the password (length, uppercase, number, special char) and renders a colored strength bar with ARIA attributes.
In the next lesson, we will explore data fetching, async components, and Vue's Suspense feature for handling asynchronous operations declaratively.
<script setup lang="ts">
import { reactive, computed } from 'vue'
interface FormData {
name: string
email: string
password: string
confirmPassword: string
role: 'viewer' | 'editor' | 'admin'
newsletter: boolean
skills: string[]
}
interface FormErrors {
name?: string
email?: string
password?: string
confirmPassword?: string
}
const form = reactive<FormData>({
name: '',
email: '',
password: '',
confirmPassword: '',
role: 'viewer',
newsletter: false,
skills: [],
})
const errors = reactive<FormErrors>({})
function validateField(field: keyof FormErrors) {
delete errors[field]
if (field === 'name' && !form.name.trim()) {
errors.name = 'Name is required'
}
if (field === 'email') {
if (!form.email.trim()) {
errors.email = 'Email is required'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = 'Enter a valid email address'
}
}
if (field === 'password') {
if (form.password.length < 8) {
errors.password = 'Password must be at least 8 characters'
}
}
if (field === 'confirmPassword') {
if (form.password !== form.confirmPassword) {
errors.confirmPassword = 'Passwords do not match'
}
}
}
const isValid = computed(() =>
!Object.keys(errors).length &&
form.name.trim() &&
form.email.trim() &&
form.password.length >= 8 &&
form.password === form.confirmPassword
)
function onSubmit() {
// Validate all fields before submission
;(['name', 'email', 'password', 'confirmPassword'] as const).forEach(validateField)
if (!isValid.value) return
console.log('Submitting:', { ...form, password: '[REDACTED]' })
}
</script>
<template>
<form novalidate @submit.prevent="onSubmit">
<div class="field">
<label for="name">Full name</label>
<input
id="name"
v-model.trim="form.name"
type="text"
autocomplete="name"
:aria-invalid="!!errors.name"
:aria-describedby="errors.name ? 'name-error' : undefined"
@blur="validateField('name')"
/>
<p v-if="errors.name" id="name-error" role="alert" class="error">
{{ errors.name }}
</p>
</div>
<div class="field">
<label for="email">Email</label>
<input
id="email"
v-model.trim="form.email"
type="email"
autocomplete="email"
:aria-invalid="!!errors.email"
@blur="validateField('email')"
/>
<p v-if="errors.email" role="alert" class="error">{{ errors.email }}</p>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="form.password"
type="password"
autocomplete="new-password"
:aria-invalid="!!errors.password"
@blur="validateField('password')"
/>
<p v-if="errors.password" role="alert" class="error">{{ errors.password }}</p>
</div>
<fieldset>
<legend>Role</legend>
<label>
<input type="radio" v-model="form.role" value="viewer" /> Viewer
</label>
<label>
<input type="radio" v-model="form.role" value="editor" /> Editor
</label>
<label>
<input type="radio" v-model="form.role" value="admin" /> Admin
</label>
</fieldset>
<fieldset>
<legend>Skills</legend>
<label>
<input type="checkbox" v-model="form.skills" value="vue" /> Vue.js
</label>
<label>
<input type="checkbox" v-model="form.skills" value="react" /> React
</label>
<label>
<input type="checkbox" v-model="form.skills" value="ts" /> TypeScript
</label>
</fieldset>
<label>
<input type="checkbox" v-model="form.newsletter" />
Subscribe to newsletter
</label>
<button type="submit" :disabled="!isValid">Register</button>
</form>
</template>
Sign in to track your progress