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.