Vue – v-model and Two-Way Binding

November 11, 20254 min readUpdated 8/24/2026

v-model is two-way binding. On a form input it is a shorthand you will use constantly; on your own components it is the single most useful pattern in Vue, and it is worth understanding what it desugars to rather than treating it as magic.

On a form input

<input v-model="form.title" class="form-control" />

is exactly:

<input
  :value="form.title"
  @input="form.title = $event.target.value"
  class="form-control"
/>

A bound value and an event handler that writes it back. That is all v-model ever is.

Vue picks the right property and event per element: value/input for text, checked/change for checkboxes and radios, and value plus change for <select>. So the same directive works everywhere:

<input v-model="form.title" />
<textarea v-model="form.description"></textarea>
<select v-model="form.status">
  <option v-for="s in STATUSES" :key="s" :value="s">{{ s }}</option>
</select>
<input type="checkbox" v-model="agreed" />
<input type="radio" v-model="mode" value="delivery" />

For a <select>, bind :value on each option when the value is not a plain string — otherwise you get the option's text content.

Input modifiers

<input v-model.trim="form.slug" />      <!-- strips leading/trailing whitespace -->
<input v-model.number="qty" />          <!-- casts to a number -->
<input v-model.lazy="form.title" />     <!-- syncs on change, not on every keystroke -->

.number is worth a note: an <input type="number"> still gives you a string, so a comparison like qty > 10 compares strings. Add .number or cast it yourself. .number leaves the value alone if it cannot be parsed, so a half-typed "1e" does not become NaN.

On your own component

Here is where it earns its place. The demo application's tag editor is a full worked example — a custom control used exactly like an input:

<TagInput v-model="form.tags" />

which desugars to the same two things as before:

<TagInput
  :model-value="form.tags"
  @update:model-value="form.tags = $event"
/>

So a component supports v-model by declaring a modelValue prop and emitting update:modelValue. Nothing else:

const props = defineProps({
  modelValue: { type: Array, default: () => [] },
  max: { type: Number, default: 8 },
});
const emit = defineEmits(["update:modelValue"]);

and every change emits a new array rather than mutating the prop:

function add() {
  const tag = slugify(draft.value);
  draft.value = "";
  if (!tag || props.modelValue.includes(tag) || props.modelValue.length >= props.max) return;
  emit("update:modelValue", [...props.modelValue, tag]);
}

Read the last line carefully: [...props.modelValue, tag]. The array belongs to the parent, so the child builds a new one and hands it over. Pushing to props.modelValue would appear to work and would be the props-are-read-only violation from lesson 10.

The whole component is 60 lines, and from the outside it is indistinguishable from an <input>. That is the pattern: anything that holds a value can be a v-model component, and the parent never needs to know how it works. The same form uses it twice — <TagInput v-model="form.tags" /> and <MediaUpload v-model="form.video" />, one editing an array of strings and the other an object describing an uploaded file.

defineModel — the modern shorthand

Vue 3.4 added a macro that collapses the prop and the emit into one line:

<script setup>
// Declares the modelValue prop AND the update:modelValue emit, and gives you
// a writable ref. Assigning to it emits.
const tags = defineModel({ type: Array, default: () => [] });

function add(tag) {
  tags.value = [...tags.value, tag];
}
</script>

That is the same component with the boilerplate gone. Use it in new code.

The demo application still writes it out longhand, which is why this lesson shows both — and it is genuinely the better thing to read first, because defineModel is not magic once you have seen what it stands for. The explicit version is also what you will find in the large majority of Vue 3 code written before 2024.

Multiple v-models

A component can have more than one, each with a name:

<DateRange v-model:from="filters.from" v-model:to="filters.to" />
const from = defineModel("from");
const to = defineModel("to");

// or longhand: props `from` and `to`, emits `update:from` and `update:to`

The trap: v-model on a prop

This is the mistake everyone makes once:

<!-- WRONG -- `title` is a prop, and props are read-only. -->
<input v-model="title" />

It writes to a prop on every keystroke. In development you get a warning per character; the value does not stick, and the input appears frozen.

The fix is to pass the v-model through rather than binding to the prop — which is exactly what defineModel gives you, and the reason it exists.

v-model does not deep-watch

v-model="form.video" replaces the object when the child emits. It does not detect the child mutating the object in place — and a child that does that is mutating the parent's data anyway. Emit a new object.

Next: Slots — passing markup instead of data.