Skip to main content

V-model in Vue.js

v-model is Vue's directive for two-way data binding on form inputs, syncing component state with user input through a value prop and input event.

Theory

TL;DR

  • v-model compiles to :value="x" @input="x = $event.target.value" under the hood
  • Works on native inputs (<input>, <textarea>, <select>) and custom components
  • Custom components: expects modelValue prop and update:modelValue emit
  • Vue 3 allows multiple named v-models on one component: v-model:firstName, v-model:lastName
  • Modifiers .lazy, .number, .trim handle the most common input transformations

Quick example

vue
<template> <input v-model="message" placeholder="Type here" /> <p>Live: {{ message }}</p> </template> <script setup> import { ref } from 'vue' const message = ref('Hello Vue') // Initial value shows in the input </script>

Typing in the input updates message instantly. The <p> tag re-renders automatically because message is a reactive ref. No event handler needed.

How it compiles

Vue's template compiler transforms v-model="text" at build time:

vue
<!-- What you write --> <input v-model="text" /> <!-- What the compiler produces --> <input :value="text" @input="text = $event.target.value" />

For custom components the output differs:

vue
<!-- What you write --> <CustomInput v-model="text" /> <!-- What the compiler produces --> <CustomInput :modelValue="text" @update:modelValue="text = $event" />

The reactivity system (Proxy-based in Vue 3) tracks the assignment and triggers re-renders for anything that reads text.

When to use

  • <input>, <textarea>, <select> - use v-model directly
  • Custom input components - v-model via modelValue + update:modelValue
  • Multiple fields on one component - named v-models like v-model:firstName
  • Non-form elements like buttons - skip v-model, use @click or manual binding
  • Complex forms with state shared across many components - consider Pinia instead of chaining v-models

Modifiers

Three built-in modifiers cover the most common input processing needs:

vue
<input v-model.lazy="text" /> <!-- Updates on blur, not every keystroke --> <input v-model.number="age" /> <!-- Casts string to number automatically --> <input v-model.trim="username" /> <!-- Strips leading/trailing whitespace --> <!-- Combine them --> <input v-model.lazy.trim="email" />

.lazy switches the listener from @input to @change. Useful for search fields where you want to avoid firing a query on every single keystroke.

v-model on custom components

The parent passes a value, the child emits updates back. That is the whole contract.

vue
<!-- Parent --> <CustomInput v-model="searchText" /> <!-- Child: CustomInput.vue --> <template> <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" /> </template> <script setup> defineProps(['modelValue']) defineEmits(['update:modelValue']) </script>

A cleaner approach wraps the logic in a computed with getter and setter. The computed getter/setter pattern is what I reach for most often in real components - it keeps the template clean and makes the data flow readable at a glance.

vue
<!-- CustomInput.vue --> <script setup> import { computed } from 'vue' const props = defineProps(['modelValue']) const emit = defineEmits(['update:modelValue']) const value = computed({ get: () => props.modelValue, set: (val) => emit('update:modelValue', val) }) </script> <template> <input v-model="value" /> </template>

Multiple v-model bindings (Vue 3)

Vue 3 removed the single-binding restriction from Vue 2. Each named v-model:propName maps to a propName prop and an update:propName emit:

vue
<!-- Parent --> <UserName v-model:firstName="firstName" v-model:lastName="lastName" /> <!-- Child: UserName.vue --> <template> <input :value="firstName" @input="$emit('update:firstName', $event.target.value)" placeholder="First name" /> <input :value="lastName" @input="$emit('update:lastName', $event.target.value)" placeholder="Last name" /> </template> <script setup> defineProps(['firstName', 'lastName']) defineEmits(['update:firstName', 'update:lastName']) </script>

Common mistakes

Mutating a prop directly. This is the most common v-model bug in Vue components.

vue
<!-- Wrong: direct prop mutation --> <template> <input v-model="modelValue" /> </template> <script setup> defineProps(['modelValue']) // Vue warns: "Avoid mutating a prop directly" </script>

Props flow one direction by design. The parent owns the data, and changes must travel back through emit. Fix it with computed:

vue
<!-- Correct: computed as a proxy --> <script setup> import { computed } from 'vue' const props = defineProps(['modelValue']) const emit = defineEmits(['update:modelValue']) const value = computed({ get: () => props.modelValue, set: (val) => emit('update:modelValue', val) }) </script>

Using v-model on contenteditable. It does nothing.

vue
<!-- No effect --> <div contenteditable v-model="text"></div>

v-model expects a value property to bind, which contenteditable elements do not have. Wire it manually:

vue
<div contenteditable="true" :innerHTML="text" @input="text = $event.target.innerText" ></div>

Wrong ref type for multiple checkboxes. When binding several checkboxes to one ref, the ref must be an array.

vue
<script setup> // Wrong: string ref breaks toggle logic const hobbies = ref('') // Correct: array tracks all checked values const hobbies = ref([]) </script> <template> <input type="checkbox" v-model="hobbies" value="coding" /> Coding <input type="checkbox" v-model="hobbies" value="skiing" /> Skiing <p>{{ hobbies }}</p> <!-- ['coding', 'skiing'] when both checked --> </template>

With an array, Vue pushes and splices values as boxes are toggled. With a string, behavior is undefined.

Real-world usage

  • Vuetify: <v-text-field v-model="form.email"> in admin dashboards
  • Quasar: <q-input v-model="state.search"> for search in mobile apps
  • Element Plus: <el-input v-model="query"> in data tables with server-side filtering
  • VeeValidate: wraps v-model to validate before emitting the update
  • Nuxt.js: v-model on user profile forms combined with useAsyncData for SSR-safe inputs

Follow-up questions

Q: What is the compiled output of <input v-model="msg" />?
A: :value="msg" @input="msg = $event.target.value". For a custom component it becomes :modelValue="msg" @update:modelValue="msg = $event".

Q: How does v-model differ between Vue 2 and Vue 3?
A: Vue 2 uses the value prop and input event, hardcoded for all components. Vue 3 uses modelValue and update:modelValue, which enables multiple named v-models like v-model:foo on a single component.

Q: What event does v-model listen to on <select multiple>?
A: change, not input. Vue handles this automatically and collects the selected options into an array.

Q: How do you implement a lazy v-model on a custom component, updating only on blur?
A: Store the intermediate value in a local ref on @input, then emit update:modelValue only in @blur. This mirrors v-model.lazy behavior but inside a custom component.

Q: Two inputs share v-model="text". What happens?
A: Race condition on @input. Whichever fires last wins, so the second input always overwrites the first. Use separate refs or a computed with getter/setter per field.

Examples

Basic: live search input

vue
<template> <input v-model="query" placeholder="Search products..." /> <p>Searching for: {{ query }}</p> </template> <script setup> import { ref } from 'vue' const query = ref('') </script>

Every keystroke updates query and the paragraph re-renders. No event handler boilerplate needed.

Intermediate: todo form (TodoMVC pattern)

vue
<template> <input v-model="newTodo" @keyup.enter="addTodo" placeholder="Add a todo..." /> <ul> <li v-for="todo in todos" :key="todo.id">{{ todo.text }}</li> </ul> </template> <script setup> import { ref } from 'vue' const newTodo = ref('') const todos = ref([]) function addTodo() { if (!newTodo.value.trim()) return todos.value.push({ id: Date.now(), text: newTodo.value.trim() }) newTodo.value = '' // v-model clears the input when you reset the ref } </script>

Press Enter: the todo appears in the list, the input clears. Setting newTodo.value = '' is enough because v-model keeps the DOM in sync with the ref.

Advanced: custom email input with validation and lazy update

vue
<!-- EmailInput.vue --> <template> <input :value="modelValue" @blur="handleBlur" @input="localValue = $event.target.value" :class="{ error: hasError }" /> <span v-if="hasError">Invalid email</span> </template> <script setup> import { ref } from 'vue' const props = defineProps({ modelValue: String }) const emit = defineEmits(['update:modelValue']) const localValue = ref(props.modelValue) const hasError = ref(false) function handleBlur() { hasError.value = !localValue.value.includes('@') if (!hasError.value) { emit('update:modelValue', localValue.value) // Only emits on valid blur } } </script>

The parent's data updates only when the user leaves the field and the value passes validation. Typing stays local until blur. This pattern appears often in form libraries like VeeValidate.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?