| |

Vue.js 13 🟢 Two-Way Data Binding on Form Inputs using v-model

A form input and a piece of component state need to stay synchronized. The user types, the state updates. The state changes, the input updates. Vue’s v-model directive expresses this two-way binding in a single attribute. Under the hood, it is shorthand for a :value binding and an @input handler—the same controlled input pattern covered in the React chapters, but with the wiring provided by the framework. The result is less boilerplate and a consistent API across every form element type.

This chapter covers v-model in full. You will learn the syntax for text inputs, textareas, checkboxes, radio buttons, select elements, and multi-selects. You will see the modifiers—.lazy, .number, .trim—that adjust when the binding updates and how the value is coerced. You will also learn how v-model works on custom components, including the modelValue prop and the update:modelValue event that make it possible.

Key point: v-model is syntactic sugar. On a native input, <input v-model="text"> is equivalent to <input :value="text" @input="text = $event.target.value">. On a custom component, it is equivalent to :modelValue="value" plus @update:modelValue="value = $event". Understanding the expansion is understanding how to customize it.


Why v-model exists

The synchronization problem. A controlled input requires two bindings: one to set the value from state, one to update state when the input changes. Writing both for every field in a form is repetitive. v-model combines them into one attribute and ensures the pairing is correct. The framework reads the input’s current value on every input event and writes it to the bound expression.

The element-type problem. Different form elements report their value differently. A text input uses event.target.value. A checkbox uses event.target.checked. A select uses event.target.value. A multi-select returns an array of selected values. The v-model directive encapsulates these differences and provides a consistent binding for each element type. A checkbox bound with v-model writes a boolean, not a string.

The modifier problem. Some form fields should update state on change rather than input, or coerce their value to a number, or trim whitespace. The .lazy, .number, and .trim modifiers express these adjustments declaratively. Without them, each handler would need to include the logic manually.

The component problem. A custom input component—a date picker, a rich text editor, a tag selector—should support v-model the same way native inputs do. Vue defines a contract: the component receives a modelValue prop and emits an update:modelValue event. When the component follows this contract, consumers can use v-model on it without knowing its internals.

The multi-model problem. Vue 3 allows multiple v-model bindings on a single component using arguments: v-model:first-name and v-model:last-name. This is an extension of the base contract that supports components with more than one bound value.


a. Text inputs, textareas, and modifiers

For text inputs and textareas, v-model binds to a string.

<template>
  <input v-model="name" placeholder="Name">
  <textarea v-model="bio" placeholder="Bio"></textarea>
  <p>Hello, {{ name }}!</p>
</template>

<script setup>
import { ref } from 'vue'
const name = ref('')
const bio = ref('')
</script>

The v-model on the input is equivalent to :value="name" and @input="name = $event.target.value". The two-way binding means the input displays name and updates it on every keystroke.

A textarea uses v-model the same way. Unlike HTML, where the value goes between the tags, Vue binds it through the directive.

Three modifiers adjust the behavior.

.lazy switches from input to change events. The binding updates when the field loses focus or the user presses Enter, rather than on every keystroke.

<input v-model.lazy="name">

This is useful for expensive operations like validation or API calls that should not run on every character.

.number coerces the input to a number. If the input cannot be parsed as a number, the raw string is used.

<input v-model.number="age" type="number">

Without .number, age would be the string '25'. With it, age is the number 25. This works with type="number" inputs and with text inputs where the user is expected to type a numeric value.

.trim trims whitespace from the input. Leading and trailing spaces are removed.

<input v-model.trim="username">

The modifiers can be chained. v-model.trim.lazy="username" trims the value and updates on change.


b. Checkboxes, radio buttons, and selects

Checkboxes bind to a boolean when used alone.

<input type="checkbox" v-model="agreed">

agreed is true when checked and false when unchecked. The binding writes event.target.checked, a boolean, not event.target.value.

Checkboxes can also bind to an array, where the checkbox represents one item in a group.

<input type="checkbox" value="apple" v-model="fruits">
<input type="checkbox" value="banana" v-model="fruits">
<input type="checkbox" value="cherry" v-model="fruits">

When checked, the value is added to the fruits array. When unchecked, it is removed. The array tracks which checkboxes are selected.

Radio buttons bind to a single value. Each radio in the group has a value, and the binding tracks which one is selected.

<input type="radio" value="small" v-model="size">
<input type="radio" value="medium" v-model="size">
<input type="radio" value="large" v-model="size">

size is 'small', 'medium', or 'large' depending on which radio is selected. All radios in the group share the same v-model binding and must have distinct value attributes.

Single selects bind to the selected option’s value.

<select v-model="country">
  <option disabled value="">Select a country</option>
  <option>USA</option>
  <option>Canada</option>
  <option>Mexico</option>
</select>

The first option is disabled and has an empty value. This shows a placeholder and ensures country starts empty. Without it, the browser selects the first option by default, and country is initialized to 'USA' before the user interacts.

Multi-selects bind to an array. The user holds Ctrl (or Cmd) to select multiple options.

<select v-model="selectedFruits" multiple>
  <option>Apple</option>
  <option>Banana</option>
  <option>Cherry</option>
</select>

selectedFruits is an array of the selected option values.

In all cases, v-model handles the value extraction. For checkboxes, it reads checked. For radios and selects, it reads value. For multi-selects, it reads the array of selected values. The consumer does not need to know these differences.


c. v-model on custom components

A custom component supports v-model by following a contract. The component declares a modelValue prop and emits an update:modelValue event when the value should change.

<!-- CustomInput.vue -->
<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  >
</template>

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

The parent uses it with v-model:

<template>
  <CustomInput v-model="name" />
</template>

<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const name = ref('')
</script>

The v-model="name" on the component expands to :modelValue="name" and @update:modelValue="name = $event". The component receives the current value through the prop and emits the new value through the event. The parent’s name ref stays in sync.

Vue 3 supports multiple v-model bindings using arguments. Each binding uses a different prop name and event name.

<!-- UserName.vue -->
<template>
  <input
    :value="firstName"
    @input="$emit('update:firstName', $event.target.value)"
  >
  <input
    :value="lastName"
    @input="$emit('update:lastName', $event.target.value)"
  >
</template>

<script setup>
defineProps(['firstName', 'lastName'])
defineEmits(['update:firstName', 'update:lastName'])
</script>

The parent uses v-model:first-name and v-model:last-name:

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

Each argument creates a separate two-way binding. The prop name and event name are derived from the argument: firstName prop and update:firstName event for v-model:first-name.

defineModel() is the modern macro for declaring a model in <script setup>. It replaces the manual prop and emit declarations.

<!-- CustomInput.vue -->
<script setup>
const model = defineModel()
</script>

<template>
  <input v-model="model">
</template>

defineModel() returns a ref that is automatically synchronized with the parent’s v-model binding. Writing to the ref emits the update:modelValue event. Reading from it gives the current prop value. This eliminates the boilerplate of declaring the prop and emit manually.

Multiple models with defineModel() use the argument as the name:

<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

The parent binds with v-model:first-name and v-model:last-name.


Complete Example Session

<!-- ============================================ -->
<!-- PART 1: TEXT INPUT -->
<!-- ============================================ -->
<template>
  <input v-model="name" placeholder="Name">
  <p>Hello, {{ name }}!</p>
</template>

<script setup>
import { ref } from 'vue'
const name = ref('')
</script>
<!-- ============================================ -->
<!-- PART 2: TEXTAREA -->
<!-- ============================================ -->
<template>
  <textarea v-model="bio" placeholder="Bio"></textarea>
  <p>{{ bio.length }} characters</p>
</template>

<script setup>
import { ref } from 'vue'
const bio = ref('')
</script>
<!-- ============================================ -->
<!-- PART 3: .lazy MODIFIER -->
<!-- ============================================ -->
<template>
  <input v-model.lazy="name">
  <!-- Updates on change, not input -->
</template>

<script setup>
import { ref } from 'vue'
const name = ref('')
</script>
<!-- ============================================ -->
<!-- PART 4: .number AND .trim -->
<!-- ============================================ -->
<template>
  <input v-model.number="age" type="number">
  <input v-model.trim="username">
</template>

<script setup>
import { ref } from 'vue'
const age = ref(0)
const username = ref('')
</script>
<!-- ============================================ -->
<!-- PART 5: SINGLE CHECKBOX -->
<!-- ============================================ -->
<template>
  <label>
    <input type="checkbox" v-model="agreed">
    I agree to the terms
  </label>
  <p>Agreed: {{ agreed }}</p>
</template>

<script setup>
import { ref } from 'vue'
const agreed = ref(false)
</script>
<!-- ============================================ -->
<!-- PART 6: CHECKBOX GROUP -->
<!-- ============================================ -->
<template>
  <label>
    <input type="checkbox" value="apple" v-model="fruits"> Apple
  </label>
  <label>
    <input type="checkbox" value="banana" v-model="fruits"> Banana
  </label>
  <label>
    <input type="checkbox" value="cherry" v-model="fruits"> Cherry
  </label>
  <p>Selected: {{ fruits.join(', ') }}</p>
</template>

<script setup>
import { ref } from 'vue'
const fruits = ref([])
</script>
<!-- ============================================ -->
<!-- PART 7: RADIO BUTTONS -->
<!-- ============================================ -->
<template>
  <label><input type="radio" value="small" v-model="size"> Small</label>
  <label><input type="radio" value="medium" v-model="size"> Medium</label>
  <label><input type="radio" value="large" v-model="size"> Large</label>
  <p>Size: {{ size }}</p>
</template>

<script setup>
import { ref } from 'vue'
const size = ref('medium')
</script>
<!-- ============================================ -->
<!-- PART 8: SELECT -->
<!-- ============================================ -->
<template>
  <select v-model="country">
    <option disabled value="">Select a country</option>
    <option>USA</option>
    <option>Canada</option>
    <option>Mexico</option>
  </select>
  <p>Country: {{ country }}</p>
</template>

<script setup>
import { ref } from 'vue'
const country = ref('')
</script>
<!-- ============================================ -->
<!-- PART 9: MULTI-SELECT -->
<!-- ============================================ -->
<template>
  <select v-model="selected" multiple>
    <option>Apple</option>
    <option>Banana</option>
    <option>Cherry</option>
  </select>
  <p>Selected: {{ selected.join(', ') }}</p>
</template>

<script setup>
import { ref } from 'vue'
const selected = ref([])
</script>
<!-- ============================================ -->
<!-- PART 10: CUSTOM COMPONENT WITH v-model -->
<!-- ============================================ -->
<!-- CustomInput.vue -->
<script setup>
const model = defineModel()
</script>

<template>
  <input v-model="model">
</template>

<!-- Parent.vue -->
<template>
  <CustomInput v-model="name" />
  <p>Name: {{ name }}</p>
</template>

<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const name = ref('')
</script>

The ten parts covered text inputs, textareas, the .lazy modifier, .number and .trim, single checkboxes, checkbox groups, radio buttons, selects, multi-selects, and custom components with defineModel().


Quick Reference

v-model by Element Type

ElementValue TypeBinding
<input type="text">Stringevent.target.value
<textarea>Stringevent.target.value
<input type="checkbox"> (alone)Booleanevent.target.checked
<input type="checkbox"> (group)Arrayvalue added/removed
<input type="radio">Stringevent.target.value
<select> (single)Stringselected value
<select multiple>Arrayselected values

Modifiers

ModifierEffect
.lazyUse change instead of input
.numberCoerce to number
.trimTrim whitespace
Chainedv-model.trim.lazy

Custom Component Contract

PropEventPurpose
modelValueupdate:modelValueDefault v-model
firstNameupdate:firstNamev-model:first-name
lastNameupdate:lastNamev-model:last-name

Expansion

TemplateExpands To
<input v-model="x">:value="x" @input="x = $event.target.value"
<Comp v-model="x">:modelValue="x" @update:modelValue="x = $event"
<Comp v-model:foo="x">:foo="x" @update:foo="x = $event"

Best Practices

✅ Do This:

<!-- Use v-model for all form inputs -->
<input v-model="name">                                       <!-- ✅ -->

<!-- Use .number for numeric inputs -->
<input v-model.number="age" type="number">                   <!-- ✅ -->

<!-- Use .trim for user input -->
<input v-model.trim="username">                              <!-- ✅ -->

<!-- Use .lazy for expensive operations -->
<input v-model.lazy="search">                                <!-- ✅ -->

<!-- Use an empty disabled option for select placeholder -->
<option disabled value="">Select</option>                    <!-- ✅ -->

<!-- Use defineModel() in custom components -->
const model = defineModel()                                  <!-- ✅ -->

<!-- Use multiple v-model for multi-value components -->
<Comp v-model:first-name="first" v-model:last-name="last" /> <!-- ✅ -->

❌ Don’t Do This:

<!-- Don't mix v-model with manual :value -->
<input v-model="name" :value="name">                         <!-- ❌ -->

<!-- Don't forget the empty option for select -->
<select v-model="country">
  <option>USA</option>                                       <!-- ⚠️ -->
</select>

<!-- Don't use v-model on non-form elements -->
<div v-model="text"></div>                                   <!-- ❌ -->

<!-- Don't use .number without type="number" or numeric context -->
<input v-model.number="name">                                <!-- ⚠️ -->

<!-- Don't mutate props directly in custom components -->
props.modelValue = 'new'                                     <!-- ❌ -->

<!-- Don't forget to emit update:modelValue -->
<Comp v-model="x" />
// Component must emit 'update:modelValue'                    <!-- ❌ -->

Common Pitfalls

PitfallWhy It HappensFix
Select starts with first valueNo empty disabled optionAdd <option disabled value="">
Checkbox binds to stringUsed value instead of checkedv-model handles it
Number is a stringNo .number modifierAdd .number
Whitespace not trimmedNo .trim modifierAdd .trim
Custom component not updatingMissing update:modelValue emitEmit the event
Multi-select binds to stringMissing multiple attributeAdd multiple
v-model ignored on componentProp name wrongUse modelValue

Real-World Examples

1. Login Form

<form @submit.prevent="login">
  <input v-model.trim="email" type="email">
  <input v-model="password" type="password">
  <button>Login</button>
</form>

2. Search with Debounce

<input v-model.lazy="query">

3. Numeric Quantity

<input v-model.number="quantity" type="number" min="1">

4. Terms Checkbox

<input type="checkbox" v-model="agreed">
<button :disabled="!agreed">Continue</button>

5. Multi-Select Tags

<select v-model="tags" multiple>
  <option v-for="tag in allTags" :key="tag">{{ tag }}</option>
</select>

6. Radio Group

<label v-for="option in options" :key="option">
  <input type="radio" :value="option" v-model="selected">
  {{ option }}
</label>

7. Custom Toggle

<script setup>
const model = defineModel()
</script>
<template>
  <button @click="model = !model">
    {{ model ? 'On' : 'Off' }}
  </button>
</template>

8. Address Form

<input v-model.trim="address.street">
<input v-model.trim="address.city">
<select v-model="address.country">...</select>

9. Rating Component

<script setup>
const rating = defineModel()
</script>
<template>
  <button v-for="n in 5" :key="n" @click="rating = n">
    {{ n <= rating ? '★' : '☆' }}
  </button>
</template>

10. Two Models

<script setup>
const first = defineModel('first')
const last = defineModel('last')
</script>
<template>
  <input v-model="first">
  <input v-model="last">
</template>

Visual

v-model Expansion

┌─────────────────────────────────────────────────────────────┐
│  NATIVE INPUT                                               │
│                                                             │
│  <input v-model="text">                                     │
│                                                             │
│  expands to:                                                │
│                                                             │
│  <input                                                    │
│    :value="text"                                            │
│    @input="text = $event.target.value"                      │
│  >                                                          │
│                                                             │
│  One attribute, two bindings.                               │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  CUSTOM COMPONENT                                           │
│                                                             │
│  <CustomInput v-model="text" />                             │
│                                                             │
│  expands to:                                                │
│                                                             │
│  <CustomInput                                              │
│    :modelValue="text"                                       │
│    @update:modelValue="text = $event"                       │
│  />                                                         │
│                                                             │
│  Prop in, event out.                                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Element Type Value Mapping

┌─────────────────────────────────────────────────────────────┐
│  TEXT INPUT                                                 │
│  <input type="text" v-model="name">                         │
│  name = 'Alice'  (string)                                   │
│                                                             │
│  CHECKBOX (single)                                          │
│  <input type="checkbox" v-model="agreed">                   │
│  agreed = true  (boolean)                                   │
│                                                             │
│  CHECKBOX (group)                                           │
│  <input type="checkbox" value="a" v-model="items">          │
│  items = ['a', 'b']  (array)                                │
│                                                             │
│  RADIO                                                      │
│  <input type="radio" value="small" v-model="size">          │
│  size = 'small'  (string)                                   │
│                                                             │
│  SELECT (single)                                            │
│  <select v-model="country">                                 │
│  country = 'USA'  (string)                                  │
│                                                             │
│  SELECT (multiple)                                          │
│  <select v-model="tags" multiple>                           │
│  tags = ['a', 'b']  (array)                                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Modifiers

┌─────────────────────────────────────────────────────────────┐
│  v-model.lazy="name"                                        │
│  Updates on change, not input                               │
│  (blur or Enter)                                            │
│                                                             │
│  v-model.number="age"                                       │
│  Coerces to number if parseable                             │
│  '25' → 25                                                  │
│                                                             │
│  v-model.trim="username"                                    │
│  Removes leading/trailing whitespace                        │
│  ' alice ' → 'alice'                                        │
│                                                             │
│  v-model.trim.lazy.number="value"                           │
│  Modifiers chain.                                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

defineModel Contract

┌─────────────────────────────────────────────────────────────┐
│  MANUAL (OLD)                                               │
│                                                             │
│  defineProps(['modelValue'])                                │
│  defineEmits(['update:modelValue'])                         │
│                                                             │
│  <input                                                     │
│    :value="modelValue"                                      │
│    @input="$emit('update:modelValue', $event.target.value)" │
│  >                                                          │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  defineModel() (MODERN)                                     │
│                                                             │
│  const model = defineModel()                                │
│                                                             │
│  <input v-model="model">                                    │
│                                                             │
│  Ref synchronized with parent v-model.                      │
│  Reading gives prop value. Writing emits update event.      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Text inputBinds string
TextareaBinds string
Checkbox (single)Binds boolean
Checkbox (group)Binds array
RadioBinds string
Select (single)Binds string
Select (multiple)Binds array
.lazyUpdate on change
.numberCoerce to number
.trimTrim whitespace
Custom component propmodelValue
Custom component eventupdate:modelValue
Modern macrodefineModel()
Multiple modelsv-model:name

Key takeaways:

  • v-model is shorthand for a value binding and an update handler. On a native input, it expands to :value and @input. On a component, it expands to :modelValue and @update:modelValue. Understanding the expansion is understanding how to customize it.
  • The binding type depends on the element. Text inputs and textareas bind strings. A single checkbox binds a boolean. A checkbox group, radio group, and multi-select bind arrays or strings depending on the configuration. v-model handles the extraction so the handler does not need to.
  • The .lazy modifier switches to change events. The binding updates when the field loses focus or the user presses Enter, rather than on every keystroke. This is useful for expensive validation or API calls.
  • The .number modifier coerces to a number. Without it, v-model on a numeric input produces a string. With it, the bound value is a number when parseable.
  • The .trim modifier removes whitespace. Leading and trailing spaces are stripped from the bound value. This is useful for usernames, emails, and other user input where whitespace is not meaningful.
  • Custom components follow a contract. A component that supports v-model declares a modelValue prop and emits an update:modelValue event. defineModel() in <script setup> declares both automatically.
  • Multiple v-model bindings use arguments. v-model:first-name binds to a firstName prop and an update:firstName event. This allows a single component to support multiple two-way bindings.
  • Select elements need a placeholder option. Without a disabled empty option, the browser selects the first option by default and the bound value starts with that value. The placeholder option ensures the binding starts empty.

Remember: v-model is the standard way to bind form inputs in Vue. It synchronizes the input’s value with a piece of state, and it adapts to the element type so that checkboxes bind booleans and multi-selects bind arrays without special handling. The modifiers adjust when and how the binding updates. Custom components participate by following the modelValue contract, and defineModel() makes that contract one line of code. Use v-model for every form input. Use the modifiers when the default behavior is not what you want. And when you build a custom input, support v-model so it works like the native elements it replaces.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!