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.
1. What You'll Learn
- How v-model works (it is really props + emit sugar)
- Building custom v-model components
- defineModel simplifications (Vue 3.4+)
- Form validation (VeeValidate / hand-written)
- Complex form design (nested / dynamic fields)
- Form serialization and submission
- File upload and preview
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:
- Lines of code: 10 → 5 (-50%)
- New fields: one line (add a key to the reactive object)
- Submitting everything: a single form object
- Validation: handled in one place
3. How v-model Works
(1) The Essence of v-model
<!-- 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
<!-- 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)
<!-- 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 YourselfOutput:
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.
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
// 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
}
<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>
(2) The VeeValidate Library (Recommended)
npm install vee-validate @vee-validate/rules
<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
const form = reactive({
user: {
name: '',
email: '',
age: 0
},
address: {
city: '',
country: ''
}
})
(2) Dynamic Fields (v-for)
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
<!-- 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
<!-- 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" />
Output:
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.
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)
Output:
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.
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:
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.
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:
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.
Renders one table: 5 rows x 4 columns (Pattern | Conciseness | Performance | Best For).
❓ FAQ
defineProps(['modelValue']) plus defineEmits(['update:modelValue']). The parent then uses v-model. In Vue 3.4+, defineModel replaces those 10 lines with 1.type="number", or you can call parseFloat() yourself.FileReader.readAsDataURL() and assign the result to <img src="...">. Upload the file itself with FormData..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.JSON.stringify(form) — FormData is only needed when files are involved. The backend simply receives JSON.defineModel('modelValue') is equivalent to the 10 lines of props.modelValue + emit('update:modelValue'), and it is fully backwards compatible.📖 Summary
- v-model is props + emit sugar; Vue 3.4+ replaces it with one defineModel line
- 5 custom v-model patterns: input / textarea / select / checkbox / modifier
- 5 modifiers: .lazy / .number / .trim / custom / combined
- Form validation: hand-written (simple) / VeeValidate (complex)
- 5 complex form topics: nesting / dynamic fields / serialization / validation / files
- A reactive object plus v-model removes 50% of the boilerplate
- File upload: FileReader + FormData
📝 Exercises
-
Basic (difficulty ⭐) Build a simple login form:
- 5 fields (username / password / email / phone / captcha)
- v-model + reactive
- Log the whole form object on submit
-
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
-
Challenge (difficulty ⭐⭐⭐) Build a complete "user registration" form system:
- 10 fields (including a nested address object and a dynamic tags array)
- VeeValidate + a Yup schema
- File upload (avatar)
- All 5 modifiers covered
- defineModel in the child components
- 5 error states
- Strong TypeScript typing