On this page

Forms and v-model: bindings, modifiers, and validation

14 min read TextCh. 4 — State and Data

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> (via for/id or aria-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

  1. 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.
  2. Custom Select component: Create a <AppSelect> component with defineModel() that accepts an options prop and renders a styled <select>. Make sure it forwards all native <select> attributes via v-bind="$attrs".
  3. 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.

Validate on blur, not on input
Validate individual fields when the user leaves the field (@blur), not on every keystroke. Only run a full validation on form submission. This gives immediate feedback without being intrusive while typing.
VeeValidate and Zod
For complex forms, consider VeeValidate (the standard Vue form validation library) combined with Zod for schema-based validation. VeeValidate integrates seamlessly with v-model and provides built-in composables: useField(), useForm().
v-model on checkboxes with arrays
When multiple checkboxes bind to the same v-model, the bound value must be an array (ref<string[]>([])). Vue automatically adds/removes the checkbox's value attribute from the array when the checkbox is checked/unchecked.
vue
<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>