Vue.js: Forms & v-model

Last updated: 2026-10-20

Forms are the core interaction of web applications — login, registration, search, and settings all depend on form handling. Vue's v-model is the "syntactic sugar" for forms: it makes two-way binding extremely short (1 line instead of 5).

Vue 3.4+'s defineModel simplifies v-model implementations even further. Real-world forms also need validation, serialization, and file uploads. This lesson covers 5 major form scenarios.

Related Directives:v-model

1. What You'll Learn


2. The Nightmare of a Login Form with "5 Copies of Boilerplate"

(1) Pain Point: 5 Form Fields, 10 Lines of v-model Code

Alice's login form has 5 fields, each bound with v-model:

5 fields = 10 lines of boilerplate. 10 fields would be 20 lines. The more complex the form, the worse it gets.

(2) The Advanced Fix: A reactive Object + defineModel

5 fields = 1 object. reactive cuts the code in half.

(3) Benefits

After the v-model cleanup:


3. How v-model Works

(1) The Essence of v-model

VUE
<!-- v-model is syntactic sugar for props + emit -->
<input v-model="searchQuery">

<!-- is equivalent to -->
<input 
  :value="searchQuery" 
  @input="searchQuery = $event.target.value"
>

(2) v-model on a Component

VUE
<!-- Parent component using v-model -->
<CustomInput v-model="searchQuery" />

<!-- is equivalent to -->
<CustomInput 
  :modelValue="searchQuery" 
  @update:modelValue="searchQuery = $event"
/>

(3) defineModel (The Vue 3.4+ Shortcut)


4. 5 Kinds of Custom v-model

(1) Basic Input

(2) textarea

(3) select

(4) checkbox (Multiple Selection)

(5) A Custom Modifier (trim)

VUE
<!-- Parent component: v-model.trim -->
<CustomInput v-model.trim="username" />

<!-- Child component: handling the trim modifier -->
<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])

function onInput(e) {
  let value = e.target.value
  if (props.modelModifiers.trim) {
    value = value.trim()
  }
  emit('update:modelValue', value)
}
</script>

▶ Example: 5 Custom v-model Patterns + Multiple v-model Cheat Sheet

▶ Try it Yourself

Output:

TEXT
Nothing renders on its own — this is a reference snippet.
Wired into a real component, the plain v-model keeps search in sync on every keystroke, while the multi-model form binds two independent refs (user and pass) to the same child through v-model:username and v-model:password.
TEXT
v-model="search"                    -> emits update:modelValue
v-model:username="user"             -> emits update:username
v-model:password="pass"             -> emits update:password
Two independent refs, one child component.

5. Form Validation

(1) Hand-Written Validation

JS
// utils/validators.js
export function isEmail(value) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}

export function isPhone(value) {
  return /^1[3-9]\d{9}$/.test(value)
}

export function minLength(value, min) {
  return value.length >= min
}
VUE
<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="form.email" @blur="validateField('email')">
    <p v-if="errors.email" class="error">{{ errors.email }}</p>
    
    <input v-model="form.password" type="password">
    <p v-if="errors.password">{{ errors.password }}</p>
    
    <button :disabled="!isValid">Submit</button>
  </form>
</template>

<script setup>
const { reactive, ref } = Vue
import { isEmail, minLength } from '@/utils/validators'

const form = reactive({
  email: '',
  password: ''
})

const errors = reactive({})
const isValid = ref(false)

function validateField(field) {
  if (field === 'email') {
    if (!form.email) {
      errors.email = 'Email is required'
    } else if (!isEmail(form.email)) {
      errors.email = 'Invalid email format'
    } else {
      delete errors.email
    }
  }
  
  if (field === 'password') {
    if (!form.password) {
      errors.password = 'Password is required'
    } else if (!minLength(form.password, 8)) {
      errors.password = 'Password must be 8+ characters'
    } else {
      delete errors.password
    }
  }
  
  isValid.value = Object.keys(errors).length === 0
}

function handleSubmit() {
  Object.keys(form).forEach(validateField)
  if (isValid.value) {
    console.log('Submit:', form)
  }
}
</script>
BASH
npm install vee-validate @vee-validate/rules
VUE
<template>
  <Form @submit="handleSubmit" :validation-schema="schema">
    <Field name="email" type="email" v-model="form.email" />
    <ErrorMessage name="email" />
    
    <Field name="password" type="password" v-model="form.password" />
    <ErrorMessage name="password" />
    
    <button>Submit</button>
  </Form>
</template>

<script setup>
import { Form, Field, ErrorMessage } from 'vee-validate'
import * as yup from 'yup'

const schema = yup.object({
  email: yup.string().email().required(),
  password: yup.string().min(8).required()
})

const form = reactive({ email: '', password: '' })

function handleSubmit(values) {
  console.log('Valid:', values)
}
</script>

6. Complex Form Design

(1) Nested Forms

JS
const form = reactive({
  user: {
    name: '',
    email: '',
    age: 0
  },
  address: {
    city: '',
    country: ''
  }
})

(2) Dynamic Fields (v-for)

JS
const form = reactive({
  items: [
    { name: '', quantity: 1, price: 0 }
  ]
})

function addItem() {
  form.items.push({ name: '', quantity: 1, price: 0 })
}

function removeItem(index) {
  form.items.splice(index, 1)
}

(3) The 5 Form Modifiers

VUE
<!-- 1. .lazy: updates on the change event instead of on input -->
<input v-model.lazy="email">

<!-- 2. .number: converts to a number automatically -->
<input v-model.number="age" type="number">

<!-- 3. .trim: trims whitespace automatically -->
<input v-model.trim="username">

<!-- 4. A custom modifier (CustomInput) -->
<CustomInput v-model.trim="text" />

<!-- 5. .debounce (a custom modifier): implemented inside DebouncedInput through modelModifiers -->
<DebouncedInput v-model.debounce="searchText" />

▶ Example: The 5 Form Modifiers

VUE
<!-- 1. .lazy: change event (after blur) -->
<input v-model.lazy="email">

<!-- 2. .number: converts to a number automatically -->
<input v-model.number="age" type="number">

<!-- 3. .trim: trims automatically -->
<input v-model.trim="username">

<!-- 4. Combining modifiers -->
<input v-model.lazy.trim="email">

<!-- 5. A custom modifier (child component) -->
<CustomInput v-model.trim="text" />
▶ Try it Yourself

Output:

TEXT
A pure reference snippet showing the five modifier forms.
The practical difference shows up while typing: .lazy only syncs after the field loses focus, .number turns "42" into 42, .trim strips the surrounding spaces, and .lazy.trim does both at once.
TEXT
type " 42 " with .lazy  -> value updates on blur
type " 42 " with .number-> typeof value === 'number' (42)
type " 42 " with .trim  -> "42"
type " 42 " with .lazy.trim -> "42" after blur

7. File Upload

(1) Single File Upload

(2) Multiple Files + Drag and Drop


8. Putting It Together: A Registration Form with Validation

▶ Example: The 5 v-model Basics + Validation

▶ Expand runnable example (63 lines)
▶ Try it Yourself

Output:

TEXT
Five stacked inputs plus a disabled submit button.
Leaving a field empty on blur prints its error underneath (for example Required on the username), a bad email prints Invalid email, an age below 18 prints Age must be 18-120, and a mismatched confirmation prints Passwords do not match.
Only when every field passes does the green "✅ Form passed validation!
Ready to submit" line appear and the button enable;
clicking it then alerts the serialized form.
TEXT
Empty username        -> Required
"alice"               -> Invalid email
age 16                -> Age must be 18-120
password "123"        -> Password must be ≥8 characters
mismatched confirm    -> Passwords do not match
all valid             -> ✅ Form passed validation! Ready to submit   (button enabled)

(1) 📊 5 Common Mistakes at a Glance

Mistake Symptom Fix
v-model.number still yields a string Arithmetic goes wrong type="number" + .number
v-model stops updating Switching ref → reactive breaks it Use .value or toRefs
File upload fails with FormData CORS or a wrong method fetch + FormData
Validation fires at the wrong time Errors appear before blur Use @blur
Boilerplate for many fields Verbose code Use one reactive object

Output:

TEXT
A five-row troubleshooting table for the form bugs that show up most often — untyped numbers, broken reactivity after a ref-to-reactive switch, failing uploads, and validation timing — each with a one-line fix.
TEXT
Renders one table: 5 rows x 3 columns (Mistake | Symptom | Fix).

(2) 📊 5-Way Performance Comparison

Pattern Conciseness Performance Best For
Multiple refs ❌ Repetitive ⭐⭐⭐⭐ 1-2 fields
reactive object ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 5+ fields
Manual FormData ❌ ⭐⭐⭐ File uploads
VeeValidate ⭐⭐⭐⭐ ⭐⭐⭐ Complex validation
defineModel ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ Vue 3.4+ components

Output:

TEXT
A five-row comparison table.
The takeaway: switch from separate refs to a single reactive object once a form passes two fields, and reach for defineModel whenever the form lives inside a reusable child component.
TEXT
Renders one table: 5 rows x 4 columns (Pattern | Conciseness | Performance | Best For).

Related Directives:v-model

❓ FAQ

Q How does v-model work on a custom component?
A Inside the component, use defineProps(['modelValue']) plus defineEmits(['update:modelValue']). The parent then uses v-model. In Vue 3.4+, defineModel replaces those 10 lines with 1.
Q Why does v-model.number not work?
A It must be paired with type="number", or you can call parseFloat() yourself.
Q Should I hand-write validation or use VeeValidate?
A Hand-write simple forms (fewer than 5 fields); use VeeValidate for complex ones (5+ fields, nesting, async validation).
Q How do I upload a file and show a preview?
A Read it as a base64 string with FileReader.readAsDataURL() and assign the result to <img src="...">. Upload the file itself with FormData.
Q What is the difference between .lazy and .debounce?
A .lazy updates on the change event (after blur), which avoids per-keystroke validation. .debounce is a custom modifier you implement in the child component; it fires once, 300 ms after the last input.
Q How do I serialize form data to JSON for submission?
A Just use JSON.stringify(form) — FormData is only needed when files are involved. The backend simply receives JSON.
Q Does defineModel fully replace the verbose v-model pattern?
A Yes. defineModel('modelValue') is equivalent to the 10 lines of props.modelValue + emit('update:modelValue'), and it is fully backwards compatible.

📖 Summary


📝 Exercises

  1. Basic (difficulty ⭐) Build a simple login form:

    • 5 fields (username / password / email / phone / captcha)
    • v-model + reactive
    • Log the whole form object on submit
  2. Intermediate (difficulty ⭐⭐) Add form validation:

    • 5 fields plus hand-written rules (email format, phone format, password length)
    • Validate on @blur
    • Show error messages
    • Enable/disable the submit button from isValid
  3. Challenge (difficulty ⭐⭐⭐) Build a complete "user registration" form system:

    1. 10 fields (including a nested address object and a dynamic tags array)
    2. VeeValidate + a Yup schema
    3. File upload (avatar)
    4. All 5 modifiers covered
    5. defineModel in the child components
    6. 5 error states
    7. Strong TypeScript typing
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏