Form Handling: v-model Compilation, Validation Patterns and VeeValidate Internals
v-model behaves differently across form elements — <input type="checkbox"> binds checked and change, while <input type="text"> binds value and input, and <select> follows yet another pattern. These differences aren't accidents — they're precise mappings of native browser behavior. Understanding v-model's compilation expansion means understanding how Vue finely controls DOM events beneath the facade of "declarative two-way binding."
Level 1 · What You Need to Know (1–3 Years Experience)
v-model Expansion for Native Form Elements
text/textarea: binds value + input event
<input v-model="text" />
<!-- equivalent to -->
<input :value="text" @input="text = $event.target.value" />
checkbox (single): binds checked + change event
<input type="checkbox" v-model="checked" />
<!-- equivalent to -->
<input type="checkbox"
:checked="checked"
@change="checked = $event.target.checked" />
checkbox (multiple, array binding):
<input type="checkbox" value="apple" v-model="selectedFruits" />
<input type="checkbox" value="banana" v-model="selectedFruits" />
<!-- selectedFruits is a string array; checking adds the value, unchecking removes it -->
radio: binds value + change event
<input type="radio" value="male" v-model="gender" />
<input type="radio" value="female" v-model="gender" />
<!-- equivalent to -->
<input type="radio" value="male"
:checked="gender === 'male'"
@change="gender = 'male'" />
select (single): binds value + change event
<select v-model="city">
<option value="beijing">Beijing</option>
<option value="shanghai">Shanghai</option>
</select>
<!-- equivalent to -->
<select :value="city" @change="city = $event.target.value">
...
</select>
select (multiple): array binding
<select v-model="selectedCities" multiple>
<option value="beijing">Beijing</option>
<option value="shanghai">Shanghai</option>
</select>
<!-- selectedCities is an array -->
v-model Modifiers
.lazy: binds change instead of input (syncs when user leaves the field)
<input v-model.lazy="text" />
<!-- equivalent to -->
<input :value="text" @change="text = $event.target.value" />
Useful for reducing unnecessary reactive updates, or for validating only on submit.
.number: auto-converts to number (using parseFloat)
<input type="number" v-model.number="price" />
<!-- price is number type, not string -->
.trim: auto-strips leading/trailing whitespace
<input v-model.trim="username" />
<!-- username always has trimmed whitespace -->
Custom Component v-model
Using v-model on a custom component:
<!-- Parent -->
<CustomInput v-model="text" />
<!-- equivalent to -->
<CustomInput :modelValue="text" @update:modelValue="text = $event" />
The child component accepts modelValue prop and emits update:modelValue:
<!-- CustomInput.vue (old approach) -->
<script setup>
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input :value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)" />
</template>
Vue 3.4+ recommended: defineModel()
<!-- CustomInput.vue (new approach) -->
<script setup>
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>
Multiple v-model (Named Bindings)
<!-- Parent -->
<UserEditor
v-model:name="user.name"
v-model:email="user.email"
/>
<!-- equivalent to -->
<UserEditor
:name="user.name" @update:name="user.name = $event"
:email="user.email" @update:email="user.email = $event"
/>
<!-- UserEditor.vue -->
<script setup>
const name = defineModel<string>('name')
const email = defineModel<string>('email')
</script>
<template>
<input v-model="name" />
<input v-model="email" type="email" />
</template>
Level 2 · How It Actually Works (3–5 Years Experience)
The Compiler's v-model Processing Logic
When processing v-model directives, Vue's compiler selects different expansion strategies based on element type:
// packages/compiler-dom/src/transforms/vModel.ts (simplified)
export const transformModel: DirectiveTransform = (dir, node, context) => {
const { tag } = node
if (tag === 'input') {
const type = findInputType(node)
if (type === 'checkbox') {
return genCheckboxModel(node, dir)
} else if (type === 'radio') {
return genRadioModel(node, dir)
} else if (type === 'file') {
warn('v-model cannot be used on file inputs...')
} else {
return genTextModel(node, dir)
}
} else if (tag === 'select') {
return genSelectModel(node, dir)
} else if (tag === 'textarea') {
return genTextModel(node, dir)
} else {
// Custom component: generate modelValue + update:modelValue
return genComponentModel(node, dir, context)
}
}
Full Before/After Compilation Comparison
Input (template):
<input v-model.trim.lazy="username" type="text" />
Compiler output (simplified):
createVNode('input', {
type: 'text',
value: username,
onChange: $event => { // .lazy → change instead of input
username = $event.target.value.trim() // .trim → auto-trim
}
})
Multiple modifiers combine: .lazy changes event name to change, .trim applies .trim(), .number applies parseFloat().
Special Handling for checkbox v-model
Two modes:
Mode 1: Boolean binding (single checkbox, no value attribute)
<input type="checkbox" v-model="isChecked" />
<!-- isChecked is true/false -->
Expansion:
{
checked: isChecked,
onChange: $event => { isChecked = $event.target.checked }
}
Mode 2: Array binding (multiple checkboxes, with value attribute)
<input type="checkbox" value="apple" v-model="fruits" />
Expansion:
{
checked: looseIndexOf(fruits, 'apple') > -1,
onChange: $event => {
if ($event.target.checked) {
looseIndexOf(fruits, 'apple') < 0 && fruits.push('apple')
} else {
const index = looseIndexOf(fruits, 'apple')
index > -1 && fruits.splice(index, 1)
}
}
}
looseIndexOf is Vue's internal loose-comparison function, supporting both object reference and primitive value comparison.
defineModel Macro Expansion (Vue 3.4+)
// Compiler expands defineModel() into useModel() call
// packages/runtime-core/src/helpers/useModel.ts
export function useModel<M extends PropertyKey, T>(
props: Record<M, T>,
name: M,
options?: UseModelOptions<T>,
): WritableComputedRef<T> {
const i = getCurrentInstance()!
// Returns a read-write computed ref
return computed({
get() {
return props[name]
},
set(value) {
i.emit(`update:${String(name)}`, value)
}
})
}
defineModel() returns a WritableComputedRef<T> — reading from props, writing by triggering emit. This lets child components use v-model directly on the ref, achieving transparent two-way binding.
Custom Validation Composable: useField Implementation
// useField.ts
import { ref, computed } from 'vue'
import type { Ref } from 'vue'
type Validator<T> = (value: T) => string | true
interface FieldOptions<T> {
initialValue: T
validators?: Validator<T>[]
}
export function useField<T>(options: FieldOptions<T>) {
const { initialValue, validators = [] } = options
const value = ref<T>(initialValue) as Ref<T>
const error = ref<string>('')
const touched = ref(false) // has user interacted?
const dirty = ref(false) // has value changed?
function validate(): boolean {
for (const validator of validators) {
const result = validator(value.value)
if (result !== true) {
error.value = result
return false
}
}
error.value = ''
return true
}
function handleChange(newValue: T) {
value.value = newValue
dirty.value = true
if (touched.value) validate() // validate immediately after first touch
}
function handleBlur() {
touched.value = true
validate()
}
const isValid = computed(() => error.value === '')
return { value, error, touched, dirty, isValid, validate, handleChange, handleBlur }
}
Usage with reusable validator functions:
// Pure validator functions — reusable and testable
const required = (msg = 'This field is required') => (v: unknown) =>
(v !== null && v !== undefined && v !== '') || msg
const minLength = (min: number) => (v: string) =>
v.length >= min || `Minimum ${min} characters`
const email = (v: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Please enter a valid email'
// In a component
const { value: username, error: usernameError, handleBlur } = useField({
initialValue: '',
validators: [required(), minLength(3)]
})
VeeValidate 4 Internal Architecture
VeeValidate 4 uses provide/inject to build form context:
useForm() call
└─ Creates FormContext
├─ fields: Map<string, FieldContext>
├─ errors: Ref<Record<string, string>>
├─ values: Ref<Record<string, unknown>>
└─ provide(FormContextKey, formContext)
│
▼
useField('username') call (in descendant components)
└─ inject(FormContextKey) → get form context
└─ Register self: formContext.register(field)
└─ Returns { value, error, handleChange, ... }
│
▼
handleSubmit(onSubmit) call
└─ Iterate all registered fields, call field.validate()
└─ All pass → call onSubmit(values)
└─ Any error → set errors, don't call onSubmit
<script setup>
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'
const { handleSubmit, errors } = useForm({
validationSchema: yup.object({
username: yup.string().required().min(3),
email: yup.string().required().email(),
password: yup.string().required().min(8),
})
})
const { value: username } = useField('username')
const { value: email } = useField('email')
const { value: password } = useField('password')
const onSubmit = handleSubmit(async (values) => {
await api.register(values)
// handleSubmit ensures this only runs when all fields are valid
})
</script>
<template>
<form @submit.prevent="onSubmit">
<div>
<input v-model="username" />
<span>{{ errors.username }}</span>
</div>
<div>
<input v-model="email" type="email" />
<span>{{ errors.email }}</span>
</div>
<button type="submit">Register</button>
</form>
</template>
Level 3 · Design Documents and Source Code (Senior Developers)
vModelText Directive Runtime Implementation
// packages/runtime-dom/src/directives/vModelText.ts
export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
created(el, { modifiers: { lazy, trim, number } }, vnode) {
el._assign = getModelAssigner(vnode)
// .lazy → listen to change, otherwise listen to input
addEventListener(el, lazy ? 'change' : 'input', e => {
if ((e.target as any).composing) return // handle IME input
let domValue: string | number = el.value
if (trim) {
domValue = domValue.trim()
}
if (number || el.type === 'number') {
domValue = looseToNumber(domValue) // parseFloat + NaN handling
}
el._assign(domValue)
})
if (trim) {
addEventListener(el, 'change', () => {
el.value = el.value.trim()
})
}
},
// Sync to DOM when bound value changes externally
beforeMount(el, { value }) {
el.value = value == null ? '' : value
},
beforeUpdate(el, { value, modifiers: { lazy, trim, number } }) {
el._assign = getModelAssigner(vnode)
// If el is focused (user is typing), don't override user input
if (document.activeElement === el) {
if (lazy) return
if (trim && el.value.trim() === value) return
}
const newValue = value == null ? '' : value
if (el.value !== newValue) {
el.value = newValue
}
},
}
Key detail: The composing check handles IME (Input Method Editor) composition — candidate characters during Chinese/Japanese input should not trigger v-model updates.
looseIndexOf and looseEqual: Core Utilities for Array v-model
// packages/shared/src/looseEqual.ts
export function looseEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
const isObjectA = isObject(a)
const isObjectB = isObject(b)
if (isObjectA && isObjectB) {
const keysA = Object.keys(a as object)
const keysB = Object.keys(b as object)
return (
keysA.length === keysB.length &&
keysA.every(key => looseEqual((a as any)[key], (b as any)[key]))
)
} else if (!isObjectA && !isObjectB) {
return String(a) === String(b) // primitive string comparison
}
return false
}
export function looseIndexOf(arr: unknown[], val: unknown): number {
return arr.findIndex(item => looseEqual(item, val))
}
This explains why checkbox array v-model correctly handles object options — even objects with different references but identical content are correctly found and removed.
VeeValidate FormContext Type System
// vee-validate internal core types
interface FormContext {
register: (field: PrivateFieldComposite) => void
unregister: (field: PrivateFieldComposite) => void
values: Readonly<GenericObject>
errors: Readonly<Ref<Record<string, string | undefined>>>
validate: () => Promise<{ valid: boolean; results: Record<string, ValidationResult> }>
validateField: (field: string) => Promise<ValidationResult>
handleSubmit: <TReturn = unknown>(
cb: SubmissionHandler<GenericObject, GenericObject, TReturn>
) => (e?: Event) => Promise<TReturn | undefined>
isSubmitting: Ref<boolean>
submitCount: Ref<number>
meta: ComputedRef<FormMeta<GenericObject>>
}
yup/zod Integration: Schema Validation
// yup schema converted to VeeValidate validation rules
import * as yup from 'yup'
import { toTypedSchema } from '@vee-validate/yup'
const schema = toTypedSchema(
yup.object({
username: yup.string().required('Username required').min(3, 'Min 3 chars'),
email: yup.string().required().email('Enter valid email'),
age: yup.number().required().min(18, 'Must be 18+'),
})
)
const { handleSubmit } = useForm({ validationSchema: schema })
// zod alternative
import { z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
const schema = toTypedSchema(
z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().min(18),
})
)
Level 4 · Edge Cases and Pitfalls (All Levels)
Pitfall 1: Custom Component Doesn't Emit update:modelValue
<!-- ❌ Wrong: mutating props without emit -->
<script setup>
const props = defineProps<{ modelValue: string }>()
function handleInput(e: InputEvent) {
// ❌ Directly mutating props violates one-directional data flow
props.modelValue = (e.target as HTMLInputElement).value
}
</script>
<!-- ✅ Correct: emit update:modelValue -->
<script setup>
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
function handleInput(e: InputEvent) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
</script>
<!-- Or use defineModel (Vue 3.4+, most concise) -->
<script setup>
const model = defineModel<string>()
</script>
Symptom: The input seems to work but the parent's value never updates (one-directional binding behavior).
Pitfall 2: .number Modifier Produces NaN on Empty Input
// ❌ When user clears the input field
// <input type="number" v-model.number="price" />
// User types "123" → price = 123 ✓
// User clears field → price = NaN ✗
// NaN is reactive, but usually not the desired default
// ✅ Solution: handle NaN explicitly
const rawPrice = ref('')
const numericPrice = computed(() => {
const n = parseFloat(rawPrice.value)
return isNaN(n) ? 0 : n
})
Pitfall 3: v-model with Destructured Reactive Properties
// ❌ v-model on destructured reactive property loses reactivity
const { username } = reactive(formState)
// <input v-model="username" /> — changes won't reflect in formState
// ✅ Bind directly to the object property
// <input v-model="formState.username" />
// ✅ Or use a ref
const username = ref(formState.username)
watch(username, (v) => formState.username = v)
Pitfall 4: Race Conditions in Async Validation
// ❌ Race condition: user types fast, multiple async validations run concurrently
watch(email, async (v) => {
const isUnique = await checkEmailUnique(v)
emailError.value = isUnique ? '' : 'Email already taken'
// If user typed again while waiting, this result is stale!
})
// ✅ Use onCleanup to handle race condition
watch(email, async (v, _, onCleanup) => {
let cancelled = false
onCleanup(() => { cancelled = true })
const isUnique = await checkEmailUnique(v)
if (!cancelled) {
emailError.value = isUnique ? '' : 'Email already taken'
}
})
Pitfall 5: select v-model Initialization with No Matching Option
<!-- ❌ Problem: initial value doesn't match any option, select shows blank -->
<select v-model="selected">
<option value="a">Option A</option>
<option value="b">Option B</option>
</select>
const selected = ref('') // ← no option with value '' → blank display
<!-- ✅ Solution: add placeholder option, or ensure initial value matches -->
<select v-model="selected">
<option value="" disabled>Please select...</option>
<option value="a">Option A</option>
<option value="b">Option B</option>
</select>
Pitfall 6: Custom Component v-model Modifier Support
When a parent uses v-model.trim on a custom component, modifier information is passed via modelModifiers prop:
<!-- Parent -->
<CustomInput v-model.trim.uppercase="text" />
<!-- equivalent to -->
<CustomInput
:modelValue="text"
:modelModifiers="{ trim: true, uppercase: true }"
@update:modelValue="text = $event"
/>
// Child handling modifiers
const props = defineProps<{
modelValue: string
modelModifiers?: { trim?: boolean; uppercase?: boolean }
}>()
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
function handleInput(e: InputEvent) {
let value = (e.target as HTMLInputElement).value
if (props.modelModifiers?.trim) value = value.trim()
if (props.modelModifiers?.uppercase) value = value.toUpperCase()
emit('update:modelValue', value)
}
Chapter Summary
-
v-model is carefully designed syntax sugar for different form elements: text/textarea binds
value + input, checkbox bindschecked + change, radio and select each map to their respective native attributes and events. The compiler automatically selects the correct expansion strategy based on element type — developers don't need to memorize each element's binding details. -
Modifiers are compile-time event and value processing logic:
.lazychanges the event frominputtochange,.trimauto-calls.trim()on value updates,.numberusesparseFloat()for conversion. Multiple modifiers can be combined; the compiler merges them into a single event handler function. -
Custom component v-model is essentially
modelValueprop +update:modelValueemit: Vue 3.4'sdefineModel()macro wraps this pattern into a single line, returning aWritableComputedRefthat internally handles props reading and emit triggering — making it completely transparent. -
VeeValidate 4 uses provide/inject to build form context:
useForm()creates and providesFormContext;useField()injects the context and registers the field.handleSubmit()validates all registered fields before executing the callback — any field failure blocks form submission. This architecture requires no global state, relying entirely on Vue's component tree dependency injection. -
Four form validation design principles: (1) Immediate feedback vs. submit-only validation — use
.lazy+touchedstate to control when feedback appears; (2) Async validation must handle race conditions — useonCleanupto cancel stale requests; (3) Validator functions should be pure — easy to reuse and test; (4) Client-side validation cannot replace server-side validation — always validate on the server for security.