09 — Forms & v-model

v-model Modifier Expansion — What the Compiler Generates

ModifierInternals.vue
<script setup>
import { ref } from 'vue'

const text = ref('')
const age = ref(0)
const name = ref('')
</script>

<template>
  <!-- ── v-model="text" expands to: ──────────────────────── -->
  <!-- :value="text" @input="text = $event.target.value" -->
  <!-- Vue uses the vModelText directive, not just attr binding:
       handles IME composition events, edge cases with type=number, etc. -->
  <input v-model="text" />

  <!-- ── .lazy: listen to 'change' (on blur) not 'input' (every keystroke) ── -->
  <!-- Expands to: :value="text" @change="text = $event.target.value" -->
  <!-- Reduces re-render frequency for expensive form validation -->
  <input v-model.lazy="text" />

  <!-- ── .number: cast input string to Number ─────────────── -->
  <!-- If input is "42", v-model stores 42 (number), not "42" (string). -->
  <!-- Edge: empty string → "" (NOT 0), invalid → original string unchanged. -->
  <!-- Uses parseFloat internally; NaN results keep the original string. -->
  <input v-model.number="age" type="number" />

  <!-- ── .trim: strip leading/trailing whitespace ────────── -->
  <!-- "  hello  " → "hello". Does NOT strip internal spaces. -->
  <input v-model.trim="name" />

  <!-- ── Chained modifiers: .lazy.number.trim — applied left to right ── -->
  <input v-model.lazy.number.trim="age" />
</template>

Custom Input Component — v-model Contract

BaseInput.vue
<script setup>
import { computed } from 'vue'

// ── defineModel (3.4+): declarative v-model without boilerplate ──
// Auto-creates: modelValue prop + update:modelValue emit
// Can be used directly in template as a writable ref.
const model = defineModel<string>({
  default: '',          // default value if parent doesn't pass v-model
  required: false,
})

// ── Custom modifiers: modelModifiers prop ──────────────
// Parent: <BaseInput v-model.capitalize="text" />
// → modelModifiers = { capitalize: true }
const { capitalize } = defineProps({
  modelModifiers: { default: () => ({}) },
})

// ── Apply modifier on write ─────────────────────────────
function onInput(e) {
  let val = e.target.value
  if (capitalize) val = val.charAt(0).toUpperCase() + val.slice(1)
  model.value = val  // defineModel handles the emit automatically
}
</script>

<template>
  <input :value="model" @input="onInput" class="base-input" />
</template>
BaseInputLegacy.vue
<script setup>
// ── Pre-3.4: manual v-model contract ──────────────────
const props = defineProps({
  modelValue: { type: String, default: '' },
  modelModifiers: { default: () => ({}) },
})
const emit = defineEmits(['update:modelValue'])

function onInput(e) {
  let val = e.target.value
  if (props.modelModifiers.capitalize) {
    val = val.charAt(0).toUpperCase() + val.slice(1)
  }
  emit('update:modelValue', val)
}
</script>

<template>
  <input :value="modelValue" @input="onInput" />
</template>

Production Form — Reactive Validation with Schema

useFormValidation.ts
import { reactive, computed, ref, type Ref } from 'vue'

// ── Type-safe form validation composable ────────────────
// Rules: each field has an array of validator functions.
// Validator returns: true (valid) | string (error message)

type Validator<T> = (value: T) => true | string

export function useForm<T extends Record<string, any>>(
  initial: T,
  rules: { [K in keyof T]?: Validator<T[K]>[] }
) {
  const form = reactive({ ...initial })
  const touched = reactive({} as Record<keyof T, boolean>)
  const submitting = ref(false)

  // ── Errors: computed — re-evaluates when form or touched change ──
  const errors = computed(() => {
    const result: Partial<Record<keyof T, string>> = {}
    for (const key in rules) {
      // Only validate fields the user has touched (UX: no errors on load)
      if (!touched[key]) continue
      for (const validator of rules[key] ?? []) {
        const res = validator(form[key])
        if (res !== true) {
          result[key] = res
          break  // first error wins — don't stack messages
        }
      }
    }
    return result
  })

  const isValid = computed(() => Object.keys(errors.value).length === 0)

  function touch(key: keyof T) { touched[key] = true }
  function touchAll() {
    (Object.keys(form) as (keyof T)[]).forEach(k => touched[k] = true)
  }

  async function submit(handler: (form: T) => Promise<void>) {
    touchAll()  // validate all fields on submit attempt
    if (!isValid.value) return false
    submitting.value = true
    try {
      await handler(form)
      return true
    } finally {
      submitting.value = false
    }
  }

  function reset() {
    Object.assign(form, initial)
    Object.keys(touched).forEach(k => touched[k] = false)
  }

  return { form, errors, isValid, submitting, touch, touchAll, submit, reset }
}

// ── Usage: ──────────────────────────────────────────────
// const { form, errors, submit } = useForm(
//   { email: '', password: '' },
//   {
//     email: [v => !!v || 'Required', v => /.+@.+/.test(v) || 'Invalid email'],
//     password: [v => v.length >= 8 || 'Min 8 characters'],
//   }
// )

Dynamic Form Generation — Schema-Driven

DynamicForm.vue
<script setup>
import { ref, computed } from 'vue'

// ── Schema-driven form: render inputs from a config array ──
const schema = ref([
  { name: 'email', type: 'email', label: 'Email', required: true, placeholder: 'user@example.com' },
  { name: 'age', type: 'number', label: 'Age', min: 18, max: 120 },
  { name: 'bio', type: 'textarea', label: 'Bio', rows: 4 },
  { name: 'role', type: 'select', label: 'Role', options: ['admin', 'user', 'guest'] },
  { name: 'subscribe', type: 'checkbox', label: 'Subscribe to newsletter' },
])

const formData = ref({})

// ── Initialize form data from schema defaults ──
schema.value.forEach(field => {
  formData.value[field.name] = field.type === 'checkbox' ? false : ''
})

// ── Computed: map schema type → component ──
const fieldComponents = {
  text: 'input',
  email: 'input',
  number: 'input',
  textarea: 'textarea',
  select: 'select',
  checkbox: 'input',
}
</script>

<template>
  <form @submit.prevent>
    <div v-for="field in schema" :key="field.name">
      <label :for="field.name">{{ field.label }}</label>

      <!-- Text/Email/Number inputs -->
      <input
        v-if="['text', 'email', 'number'].includes(field.type)"
        :type="field.type"
        v-model="formData[field.name]"
        :placeholder="field.placeholder"
        :required="field.required"
      />

      <!-- Textarea -->
      <textarea
        v-else-if="field.type === 'textarea'"
        v-model="formData[field.name]"
        :rows="field.rows || 3"
      />

      <!-- Select -->
      <select v-else-if="field.type === 'select'" v-model="formData[field.name]">
        <option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
      </select>

      <!-- Checkbox -->
      <input
        v-else-if="field.type === 'checkbox'"
        type="checkbox"
        v-model="formData[field.name]"
      />
    </div>

    <button type="submit">Submit</button>
  </form>
</template>

Debounced Search Input — Custom Modifier

DebouncedInput.vue
<script setup>
import { ref, watch, customRef } from 'vue'

// ── Debounced ref factory: delays updates until typing pauses ──
function useDebouncedRef(initial, delay = 300) {
  let value = initial
  let timer
  return customRef((track, trigger) => ({
    get() { track(); return value },
    set(newVal) {
      clearTimeout(timer)
      timer = setTimeout(() => { value = newVal; trigger() }, delay)
    },
  }))
}

const search = useDebouncedRef('', 300)
const results = ref([])

// ── Watch fires 300ms after last keystroke, not on every keystroke ──
watch(search, async (q) => {
  if (!q) { results.value = []; return }
  const res = await fetch(`/api/search?q=${q}`)
  results.value = await res.json()
})
</script>

<template>
  <!-- Input updates immediately (user sees what they type),
       but the ref (and watchers) only update 300ms after typing stops. -->
  <input v-model="search" placeholder="Search…" />
  <ul>
    <li v-for="r in results" :key="r.id">{{ r.name }}</li>
  </ul>
</template>

Multiple v-model — Form Component Pattern

RegistrationForm.vue
<script setup>
import { computed } from 'vue'

// ── Two named v-models: one for email, one for password ──
// Parent: <RegistrationForm v-model:email="email" v-model:password="password" />
const email = defineModel<string>('email')
const password = defineModel<string>('password')

// ── Computed validation on each model ──
const emailError = computed(() => {
  if (!email.value) return ''
  return /.+@.+\..+/.test(email.value) ? '' : 'Invalid email'
})

const passwordError = computed(() => {
  if (!password.value) return ''
  return password.value.length >= 8 ? '' : 'Min 8 characters'
})

const isValid = computed(() =>
  !emailError.value && !passwordError.value && email.value && password.value
)
</script>

<template>
  <div>
    <input v-model="email" type="email" placeholder="Email" />
    <span v-if="emailError" class="error">{{ emailError }}</span>

    <input v-model="password" type="password" placeholder="Password" />
    <span v-if="passwordError" class="error">{{ passwordError }}</span>

    <button :disabled="!isValid">Register</button>
  </div>
</template>

💡 Tips & Tricks

tips.ts
// ── 1. .number edge case: parseFloat("abc") → NaN, keeps original string ──
// v-model.number on "abc" → ref stays "abc" (string), NOT NaN.
// Vue checks: if parsed is NaN, uses the original input value.

// ── 2. Checkbox v-model with true-value/false-value ──
// <input type="checkbox" v-model="agree" true-value="yes" false-value="no" />
// agree = "yes" when checked, "no" when unchecked (not true/false)

// ── 3. Radio button v-model binds to the value attribute ──
// <input type="radio" v-model="plan" value="pro" /> → plan = "pro" when selected

// ── 4. .lazy on checkboxes/selects is a no-op ──
// Checkboxes and selects fire 'change' natively, not 'input'.
// .lazy only matters for text inputs and textareas.

// ── 5. Form reset: use the form element's reset() method or reassign ──
// formRef.value?.reset()  → resets to initial HTML defaults
// Object.assign(formData, initialData)  → resets to reactive initial state

⚠️ Edge Cases & Gotchas

edge-cases.ts
// ── 1. .number with type="number" vs type="text" ──
// type="number": browser may return "" for invalid input, .number keeps ""
// type="text" + .number: "42abc" → parseFloat("42abc") = 42 (partial parse!)
// Always use type="number" with .number for proper browser validation.

// ── 2. v-model on contenteditable elements is not supported ──
// Use a manual binding: :textContent + @input handler.
// Or use a library like vue-contenteditable.

// ── 3. v-model + v-for: each item needs its own model ref ──
// ❌ <input v-for="item in items" v-model="item" /> — binds to item reference
// ✅ <input v-for="item in items" v-model="item.value" /> — binds to a property

// ── 4. defineModel is writable — but parent owns the source ──
// Writing to model.value emits update:modelValue → parent updates its ref.
// The child's model.value reflects the parent's value after the round-trip.
// In the same tick, model.value may show the old value until parent updates.

// ── 5. .trim on number inputs strips whitespace but doesn't affect parsing ──
// v-model.trim.number="age" → trims, then parses.
// "  42  " → trim → "42" → number → 42. Works as expected.

// ── 6. Custom component v-model: modelValue must match exactly ──
// defineModel() → prop name is "modelValue", emit is "update:modelValue"
// defineModel('foo') → prop name is "foo", emit is "update:foo"
// Parent: v-model="x" → modelValue, v-model:foo="x" → foo

🧠 Spot the Bug

A number input always stores a string even though .number is applied.

NumberBug.vue
<script setup>
import { ref, watch } from 'vue'
const age = ref(0)
watch(age, (v) => console.log(typeof v, v))  // always logs "string"
</script>

<template>
  <!-- Missing .number modifier — v-model stores the raw string -->
  <input v-model="age" type="number" />
</template>
Answer

v-model without .number stores the input's value as a string ($event.target.value is always a string). type="number" only controls the browser's input UI — it doesn't change what v-model stores.

Fix — add the .number modifier:

NumberFixed.vue
<template>
  <input v-model.number="age" type="number" />
</template>

Now age is stored as a number (or "" if empty, since parseFloat("") is NaN and Vue keeps the original string for that case). The lesson: type="number" controls the UI; .number controls the stored type. You need both for correct behavior.