Chapter 26

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

  1. v-model is carefully designed syntax sugar for different form elements: text/textarea binds value + input, checkbox binds checked + 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.

  2. Modifiers are compile-time event and value processing logic: .lazy changes the event from input to change, .trim auto-calls .trim() on value updates, .number uses parseFloat() for conversion. Multiple modifiers can be combined; the compiler merges them into a single event handler function.

  3. Custom component v-model is essentially modelValue prop + update:modelValue emit: Vue 3.4's defineModel() macro wraps this pattern into a single line, returning a WritableComputedRef that internally handles props reading and emit triggering โ€” making it completely transparent.

  4. VeeValidate 4 uses provide/inject to build form context: useForm() creates and provides FormContext; 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.

  5. Four form validation design principles: (1) Immediate feedback vs. submit-only validation โ€” use .lazy + touched state to control when feedback appears; (2) Async validation must handle race conditions โ€” use onCleanup to 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.

Rate this chapter
4.6  / 5  (4 ratings)

๐Ÿ’ฌ Comments