Vue defineModel: The v-model Contract, Explained
Vue's defineModel macro replaces the modelValue prop and update:modelValue event with one ref. The exact v-model contract behind it, verified against Vue 3.5.

- 1Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)14 min
- 2Vue nextTick Explained: How DOM Updates Are Batched13 min
- 3Vue Composables: The Shared State Trap (+ Cheat Sheet)13 min
- 4Vue computed(): What It Caches and When It Reruns12 min
- 5Vue defineModel: The v-model Contract, Explainedyou are here
You've written this a dozen times: declare a modelValue prop, declare an update:modelValue emit, wire a computed's get/set between them, and hope you spelled the event name identically in both places. Misspell it — updata:modelValue, update:modelvalue, anything — and nothing errors; at most a dev-mode warning scrolls past. The parent's v-model just stops updating, and you're left bisecting a component that looks correct. Vue 3.4 replaced that whole dance with a single macro, defineModel. But it isn't new magic bolted onto v-model — it's Vue writing the same prop-and-event contract for you, and once you can see that contract, every edge case here becomes predictable instead of surprising.
By the end of this article you'll be able to:
- Explain exactly what
v-modelon a component compiles down to - Replace the manual prop/emit/computed pattern with
defineModelcorrectly - Give a model a default value, make it required, and type it in TypeScript
- Bind multiple independent named models on one component
- Read the modifiers a parent attaches to
v-modeland transform values withget/set
You've built Vue 3 components with <script setup>, used defineProps and defineEmits, and put v-model on a native <input> at least once. This article is about what happens when you put v-model on your own component. If the read/write tracking underneath ref and reactive is still fuzzy, Vue Reactivity: ref vs reactive builds that foundation — useful background, not required reading.
- The Problem: The Prop/Emit Dance, By Hand
- The Mental Model: v-model Is Always a Prop and an Event
- Building It Up: How defineModel Actually Works
- Edge Cases and Gotchas
- Best Practices
- FAQ
- Cheat Sheet
- Key Takeaways
This is written against Vue 3.5.x (verified 3.5.43, released September 2026). defineModel has been stable, no-flag-required syntax since Vue 3.4 — nothing here needs the experimental flag that 3.3 required, and none of it changes in the 3.6 release candidate currently in testing: the only defineModel/useModel-related fix in 3.6 so far is a Vapor-mode-only regression fix, and this article doesn't use Vapor mode.
Say you're building a CurrencyInput component that a parent binds with v-model. Before defineModel, the idiomatic way to do this was a computed with a get and a set:
This works, and it isn't wrong — but look at what it costs. The prop name (modelValue) and the event name (update:modelValue) have to match a convention exactly, in two separate declarations, with no compiler check tying them together. Get the emit name wrong in both places and v-model on the parent's side just does nothing: no warning, no error, no update (wrong in only one place, dev mode at least warns). Multiply this by every component with a two-way binding, and by every component that needs more than one — a firstName/lastName pair, say — and you're hand-writing the same four-line skeleton over and over, each copy a fresh chance to typo an event name.
defineModel collapses that entire block into one line:
That's not a different feature — it's the same prop, the same event, and the same get/set relationship, generated for you from a single declaration.
The mental model: whatever syntax you write on the outside, v-model on a component always compiles to exactly two things — a prop bound to the current value, and a listener for an event that writes a new value back up. v-model="x" on <MyInput> is shorthand for :model-value="x" @update:model-value="x = $event". That's the entire contract. Every version of "two-way binding on a component" Vue has ever shipped — the value/input convention in Vue 2, the modelValue/update:modelValue convention in early Vue 3, and defineModel today — is just a different amount of automation over that same pair.
defineModel() is a compiler macro (available only inside <script setup>; outside it, the helper it compiles to — useModel(props, 'modelValue') — does the same job once you declare the prop and emit yourself) that, for one declaration, generates:
- A prop (
modelValueby default, or a name you choose) added to the component's props, exactly as if you'd written it indefineProps. - An emit (
update:modelValue) added to the component's emits, exactly as if you'd written it indefineEmits. - A
computed-like ref that reads the prop and, when written to, fires the emit — the same get/set relationship you'd otherwise write by hand.
The one behavior that isn't just "the same thing, shorter": if the parent doesn't bind that prop at all, the ref defineModel returns doesn't stay undefined and read-only the way a plain prop would. It falls back to behaving like an ordinary local ref, seeded with whatever default you gave it, fully writable, with no parent to sync to. That fallback is what makes defineModel usable for genuinely optional two-way bindings — a component with a sensible built-in state that a parent can optionally take over.
Key concept: model inside CurrencyInput and price inside the parent are two different refs kept in sync by the prop/emit pair defineModel wrote for you. Mutating model.value inside the child doesn't reach across the component boundary directly — it emits, and the parent's v-model catches that emit and writes price.value for you.
defineModel takes the same options a prop does, because under the hood it is one:
Typing it explicitly matters: an untyped defineModel() in a .vue file with <script setup lang="ts"> infers as loosely as an untyped prop would, so name the type rather than relying on inference from a call site that doesn't exist yet.
A component can expose more than one two-way binding by naming each defineModel call — this is the same v-model:propName syntax Vue has supported since named component v-models existed, now paired with the macro instead of hand-written props and emits:
Key concept: each named defineModel call is its own independent prop/emit pair. There's no shared state between firstName and lastName beyond what your component logic adds — they're two separate contracts, not one contract split in two.
A parent can attach modifiers to any v-model, same as v-model.trim on a native input. Read them by destructuring the second value defineModel returns, and use get/set to act on them:
Runs right in your browser — poke at it and watch the concept react live.
Object and array defaults need a factory, not a literal. This is the same rule as a regular prop default, and it's easy to forget because defineModel({ default: 0 }) looks so much like a plain value assignment: default: [] hands every instance without a bound value the same array, so mutating it in one unbound instance leaks into every other. Use default: () => [] instead.
No parent binding means a local ref, not undefined. This is the behavior that makes defineModel genuinely different from a plain required-less prop, and it's also the one detail worth testing explicitly: a component you render without v-model at all should still work, driven entirely by its own default. Vue 3.4.1 extended the fallback to parents that pass the prop one-way, without an update: listener, so on 3.5 it's simply the documented behavior, not a caveat to work around.
A default plus a parent bound to undefined desyncs. With <Child v-model="myRef" /> and myRef = ref(), the child shows its default while the parent's myRef stays undefined until the child writes. The Vue docs call this out as a warning: if the parent binds the model, give its ref a real starting value rather than leaning on the child's default.
Don't declare the same prop name in both defineProps and defineModel. defineModel('title') already declares a title prop. List title in defineProps too and the compiler won't complain — it merges both, and defineModel's options silently replace yours for that key. Any prop that isn't part of a v-model binding still belongs in an ordinary defineProps.
get runs on every read; set runs only when the component itself assigns model.value. Values arriving from the parent never pass through set, and with no parent binding set's return value is only emitted — the local ref keeps the raw value. Keep both transforms pure and cheap — no side effects, no async work. They're formatting hooks, not lifecycle hooks.
Modifiers on an unnamed model and a named model are separate namespaces. v-model.trim sets modifiers for the default (modelValue) model; v-model:title.trim sets modifiers scoped to the title model only. Destructure the modifiers from the matching defineModel call, not a shared object.
- Reach for
defineModelfor anything inherently two-way — form controls, toggles, a value the child both displays and edits. It's the direct replacement for the old computed get/set pattern, not a new category of API. - Prefer named models over overloading one
modelValuethe moment a component genuinely exposes more than one independent piece of editable state. Two named models are clearer than one object-shaped model the child has to destructure. - Type every
defineModelexplicitly in TypeScript. An inferred type from a macro with no call-site arguments is easy to get wrong silently; writedefineModel<string>()rather than leaning on inference. - Don't use
v-modelto fake a read-only prop. If the child never writes back, it's a normal prop, not a model —v-modelsignals "this can flow both ways," and using it where that's untrue misleads the next person who reads the parent's template. - Don't wire
defineModelstraight into a Pinia store.v-modelis a parent/child contract; a store is global state. Write to store actions directly and keep the two mechanisms separate, or you'll end up debugging which one actually owns the value.
No. It only replaces the declaration for props that participate in a v-model binding. Every other prop on the component still goes in an ordinary defineProps.
Not the macro itself — it only exists inside <script setup>. But the helper it compiles to, useModel() (3.4+), works in a plain setup(): declare the prop and update: emit yourself, then const model = useModel(props, 'modelValue').
Before defineModel, this was almost always a mismatched prop/emit name — modelValue declared in props but update:modalValue (typo) in the emit, or vice versa. defineModel removes that failure mode entirely, since one declaration generates both sides. If it's still not updating, check that the parent binds the name you declared (v-model:title for defineModel('title')), and that you haven't re-declared the prop in defineProps — that compiles silently and defineModel's options replace yours.
No — defineModel declares the prop and the emit for you. Adding the same event again in defineEmits is redundant but harmless — the compiler merges both lists.
No. Vue 2 has no compiler macro for this; components there declare value/input (Vue 2's default model event, before the modelValue/update:modelValue convention Vue 3 introduced) by hand, and that pattern doesn't change in the Vue 2 line. Treat any Vue 2 example that mentions defineModel as a mistake, not a version difference.
Think it clicked? Take the 9-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
| Task | Code | Notes |
|---|---|---|
| Basic model | const model = defineModel() | Prop modelValue + emit update:modelValue, generated |
| With default | defineModel({ default: 0 }) | Object/array defaults need a factory: default: () => [] |
| Required | defineModel<string>({ required: true }) | Removes undefined from the TS type |
| Named model | defineModel('title') | Parent binds with v-model:title |
| Multiple models | defineModel('a') + defineModel('b') | Fully independent prop/emit pairs |
| Read modifiers | const [m, mods] = defineModel() | mods.yourModifier is true when the parent adds .yourModifier |
| Transform value | defineModel({ get(v) {…}, set(v) { return … } }) | set runs on the child's writes (its result is what's emitted); get runs on every read |
| No parent binding | (default behavior) | Ref falls back to a local, fully writable ref seeded by default |
v-modelon a component is always a prop plus an event;defineModeldoesn't change that contract, it generates it.- With no parent binding,
defineModel's ref becomes a local, writable ref seeded by yourdefault— notundefined. - Independent two-way bindings are named models (
defineModel('name')), each its own prop/emit pair. - Modifiers arrive as the second destructured value; transform the model with
get/set, not a watcher. - The same prop rules still apply underneath: factory functions for object/array defaults, explicit typing in TypeScript.
The typo-prone emit dance from the top of this article is gone, but that was never the interesting part. v-model was never magic to begin with — it was always a prop and an event, wired by convention. defineModel just means you stop wiring it by hand, and everything you now know about that contract still applies exactly where the macro can't reach: get/set transforms, multiple named models, and the one moment it quietly falls back to a local ref.
What's the messiest custom v-model you've had to debug — a typo'd event name, a shared object default, or something else entirely? Drop it in the comments.
- Vue Composables: The Shared State Trap (+ Cheat Sheet)
- Vue nextTick Explained: How DOM Updates Are Batched
- Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.