Skip to main content
The v-model directive creates two-way data bindings on form input, textarea, and select elements.

Basic Usage

The v-model directive automatically picks the correct way to update the element based on the input type:
<template>
  <div>
    <input v-model="message" placeholder="Edit me" />
    <p>Message: {{ message }}</p>
  </div>
</template>

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

const message = ref('')
</script>
v-model will ignore the initial value, checked, or selected attributes on form elements. It always treats the current bound JavaScript state as the source of truth.

Text Input

From packages/runtime-dom/src/directives/vModel.ts:56-117, the vModelText directive handles text and textarea inputs:
<template>
  <div>
    <input v-model="text" type="text" />
    <textarea v-model="multiline" />
  </div>
</template>

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

const text = ref('')
const multiline = ref('')
</script>

How vModelText Works

From the source code:
export const vModelText: ModelDirective<
  HTMLInputElement | HTMLTextAreaElement,
  'trim' | 'number' | 'lazy'
> = {
  created(el, { modifiers: { lazy, trim, number } }, vnode) {
    el[assignKey] = getModelAssigner(vnode)
    const castToNumber = number || (vnode.props && vnode.props.type === 'number')
    
    addEventListener(el, lazy ? 'change' : 'input', e => {
      if ((e.target as any).composing) return
      el[assignKey](castValue(el.value, trim, castToNumber))
    })
    
    if (!lazy) {
      addEventListener(el, 'compositionstart', onCompositionStart)
      addEventListener(el, 'compositionend', onCompositionEnd)
      addEventListener(el, 'change', onCompositionEnd)
    }
  },
  
  mounted(el, { value }) {
    el.value = value == null ? '' : value
  },
  
  beforeUpdate(el, { value, modifiers: { lazy, trim, number } }) {
    if ((el as any).composing) return
    
    const elValue = (number || el.type === 'number') && !/^0\d/.test(el.value)
      ? looseToNumber(el.value)
      : el.value
    const newValue = value == null ? '' : value
    
    if (elValue === newValue) return
    
    el.value = newValue
  }
}

Checkbox

From packages/runtime-dom/src/directives/vModel.ts:119-172, checkboxes can bind to boolean, array, or Set:
<template>
  <div>
    <input type="checkbox" v-model="checked" id="checkbox" />
    <label for="checkbox">{{ checked }}</label>
  </div>
</template>

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

const checked = ref(false)
</script>

Checkbox with Array/Set

From packages/runtime-dom/src/directives/vModel.ts:129-149:
export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
  created(el, _, vnode) {
    el[assignKey] = getModelAssigner(vnode)
    addEventListener(el, 'change', () => {
      const modelValue = (el as any)._modelValue
      const elementValue = getValue(el)
      const checked = el.checked
      const assign = el[assignKey]
      
      if (isArray(modelValue)) {
        const index = looseIndexOf(modelValue, elementValue)
        const found = index !== -1
        if (checked && !found) {
          assign(modelValue.concat(elementValue))
        } else if (!checked && found) {
          const filtered = [...modelValue]
          filtered.splice(index, 1)
          assign(filtered)
        }
      } else if (isSet(modelValue)) {
        const cloned = new Set(modelValue)
        if (checked) {
          cloned.add(elementValue)
        } else {
          cloned.delete(elementValue)
        }
        assign(cloned)
      } else {
        assign(getCheckboxValue(el, checked))
      }
    })
  }
}

Radio Buttons

<template>
  <div>
    <input type="radio" v-model="picked" value="One" id="one" />
    <label for="one">One</label>
    
    <input type="radio" v-model="picked" value="Two" id="two" />
    <label for="two">Two</label>
    
    <p>Picked: {{ picked }}</p>
  </div>
</template>

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

const picked = ref('')
</script>

Select

<template>
  <div>
    <select v-model="selected">
      <option disabled value="">Please select one</option>
      <option>A</option>
      <option>B</option>
      <option>C</option>
    </select>
    <p>Selected: {{ selected }}</p>
  </div>
</template>

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

const selected = ref('')
</script>

Value Bindings

Bind to non-string values:
<template>
  <input
    type="checkbox"
    v-model="toggle"
    true-value="yes"
    false-value="no"
  />
  <p>{{ toggle }}</p>
</template>

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

const toggle = ref('no')
</script>

Modifiers

From packages/runtime-dom/src/directives/vModel.ts:48-52, Vue provides built-in modifiers for v-model:

.lazy

Sync after change event instead of input:
<template>
  <!-- Synced on change, not on every keystroke -->
  <input v-model.lazy="msg" />
</template>

.number

Automatically typecast to number:
<template>
  <input v-model.number="age" type="number" />
</template>

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

const age = ref(0)
// age.value is always a number
</script>
From the source:
function castValue(value: string, trim?: boolean, number?: boolean | null) {
  if (trim) value = value.trim()
  if (number) value = looseToNumber(value)
  return value
}

.trim

Automatically trim whitespace:
<template>
  <input v-model.trim="msg" />
</template>

Combining Modifiers

<template>
  <input v-model.lazy.trim="msg" />
  <input v-model.number.trim="age" type="number" />
</template>

v-model with Components

Use v-model on custom components:
<template>
  <CustomInput v-model="searchText" />
</template>

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

const searchText = ref('')
</script>

Multiple v-model Bindings

<template>
  <UserName
    v-model:first-name="first"
    v-model:last-name="last"
  />
</template>

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

const first = ref('')
const last = ref('')
</script>

Form Validation Patterns

<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <input v-model="email" type="email" />
      <span v-if="emailError" class="error">{{ emailError }}</span>
    </div>
    <button type="submit" :disabled="!isValid">Submit</button>
  </form>
</template>

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

const email = ref('')

const emailError = computed(() => {
  if (!email.value) return 'Email is required'
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
    return 'Invalid email format'
  }
  return ''
})

const isValid = computed(() => !emailError.value)

function handleSubmit() {
  if (isValid.value) {
    console.log('Form submitted:', email.value)
  }
}
</script>

Complete Form Example

<template>
  <form @submit.prevent="submitForm">
    <div>
      <label for="name">Name:</label>
      <input id="name" v-model.trim="form.name" required />
    </div>
    
    <div>
      <label for="email">Email:</label>
      <input id="email" v-model.trim="form.email" type="email" required />
    </div>
    
    <div>
      <label for="age">Age:</label>
      <input id="age" v-model.number="form.age" type="number" min="0" />
    </div>
    
    <div>
      <label>Gender:</label>
      <input type="radio" v-model="form.gender" value="male" id="male" />
      <label for="male">Male</label>
      <input type="radio" v-model="form.gender" value="female" id="female" />
      <label for="female">Female</label>
    </div>
    
    <div>
      <label>Interests:</label>
      <input type="checkbox" v-model="form.interests" value="coding" id="coding" />
      <label for="coding">Coding</label>
      <input type="checkbox" v-model="form.interests" value="music" id="music" />
      <label for="music">Music</label>
      <input type="checkbox" v-model="form.interests" value="sports" id="sports" />
      <label for="sports">Sports</label>
    </div>
    
    <div>
      <label for="country">Country:</label>
      <select id="country" v-model="form.country">
        <option value="">Select a country</option>
        <option value="us">United States</option>
        <option value="uk">United Kingdom</option>
        <option value="ca">Canada</option>
      </select>
    </div>
    
    <div>
      <label for="message">Message:</label>
      <textarea id="message" v-model="form.message" rows="4"></textarea>
    </div>
    
    <div>
      <input type="checkbox" v-model="form.terms" id="terms" required />
      <label for="terms">I agree to the terms and conditions</label>
    </div>
    
    <button type="submit">Submit</button>
  </form>
  
  <pre>{{ form }}</pre>
</template>

<script setup>
import { reactive } from 'vue'

const form = reactive({
  name: '',
  email: '',
  age: null,
  gender: '',
  interests: [],
  country: '',
  message: '',
  terms: false
})

function submitForm() {
  console.log('Form submitted:', form)
  // Handle form submission
}
</script>

Best Practices

1

Use appropriate input types

Always use the correct type attribute for inputs (email, number, tel, etc.) for better validation and mobile experience.
2

Provide initial values

Initialize all form fields with appropriate default values to avoid undefined refs.
const form = reactive({
  name: '',
  age: 0,
  interests: [] // Array for checkboxes
})
3

Use modifiers wisely

Apply .trim, .number, and .lazy modifiers where appropriate to simplify data handling.
4

Validate on blur

For better UX, validate fields on blur rather than on every keystroke.

Event Handling

Handle form events and submissions

Reactivity Fundamentals

Learn about ref() and reactive()

Components Basics

Use v-model with custom components

Build docs developers (and LLMs) love