Desugaring v-model: What the Compiler Expands On Your Behalf
v-model on a native input is well known — value down, input up. On a component, the same directive expands to a different pair, and knowing the expansion exactly is what makes custom v-model components debuggable instead of mystical:
<MyInput v-model="query" />
<!-- is literally compiled to -->
<MyInput :modelValue="query" @update:modelValue="query = $event" />
Two names make the contract: the prop modelValue and the emit update:modelValue. A manual implementation, no macro in sight:
<!-- MyInput.vue -->
<script setup>
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
Read it as the one-way round trip wearing sugar: the parent's ref flows down as modelValue, the child's input event translates to an update:modelValue emit, and the parent's listener assigns back to its ref. One owner, one writer, zero local state in the child — the reactivity loop re-renders the input with the parent's new value.
The contrast with native v-model is worth memorizing because it explains error messages:
| Target | Prop side | Event side |
|---|---|---|
native <input> |
value (or checked) |
input (or change) |
| component (default) | modelValue |
update:modelValue |
| component (argument) | the argument, e.g. title |
update:title |
Binding v-model to a component that implements plain value/input instead — a common React-ism — produces a component that renders nothing and warns; the names are part of the framework contract, not a convention you can pick per component.
Modifiers ride along as data. v-model.trim on a component does not auto-trim anything; the compiler passes the modifiers as a modelValueModifiers prop ({ trim: true }, or false when none were used), and the child decides what to do with them:
const props = defineProps<{
modelValue: string
modelValueModifiers?: { trim?: boolean } | false
}>()
function onInput(e) {
let v = e.target.value
if (props.modelValueModifiers?.trim) v = v.trim()
emit('update:modelValue', v)
}
Native v-model has built-in .lazy/.number/.trim semantics because the framework implements them for native elements. Component v-model hands you the data and makes behavior the child's job — which is the feature: a date picker can treat .format however its domain needs.
Attribute fallthrough is the quiet feature that keeps wrapper inputs usable. A single-root <input> wrapper automatically receives every undeclared attribute on its root element — id, placeholder, name, aria-describedby, data-testid — so consumers can label and target the inner input without the wrapper declaring anything:
<MyInput v-model="q" id="search" placeholder="Search orders" aria-label="Search" />
<!-- the id and ARIA land on the real <input> automatically -->
The failure mode arrives the day the wrapper grows a second root element — a label plus an input, an input plus an icon slot. Fallthrough has no single target anymore, Vue warns Extraneous non-props attributes were passed to component but could not be automatically inherited because component renders multiple root nodes, and every id and aria-* binding silently stops working. The repair is deliberate: defineOptions({ inheritAttrs: false }), then spread the pieces where they belong — v-bind="$attrs" on the input itself, role/aria-* on the wrapper. Screen-reader wiring depends on this chain, so test it the moment a wrapper's structure changes.
Two implementation details that bite in review: the input must use :value + @input, not v-model inside the wrapper (v-modeling the inner native input to a local ref would create exactly the second-state problem the whole pattern avoids), and the emit should carry the normalized value — clamping, trimming, and coercion belong at this boundary so the parent stores clean data. With the contract clear, the next chapter replaces this boilerplate with the macro Vue shipped for it — without changing a single one of these semantics.
defineModel: One Line That Replaces the Prop/ Emit Pair (Vue 3.4+)
Since Vue 3.4, the prop-plus-emit ceremony above is a macro:
<script setup lang="ts">
const model = defineModel<string>({ default: '' })
function onInput(e) { model.value = e.target.value } // emit happens inside
</script>
<template>
<input :value="model" @input="onInput" />
</template>
defineModel() returns a ref synchronized with the bound prop. The mechanics underneath are exactly the manual contract — it reads modelValue, and writing .value emits update:modelValue — but the macro generates the declaration, the typing, and the wiring for you, and Vue uses the implicit prop/emit pair without them appearing in your source.
The synchronization rule is the part to internalize, because it differs from naive intuition:
The prop remains the source of truth. Writing model.value = 'x' emits upward; the ref's value updates only when the parent accepts the event and passes the new prop back. If the parent binds v-model but its handler conditionally rejects values (validation, max length, server-authoritative state), a rejected write reverts — the child never holds divergent local state. That is deliberate: one-way data flow survives even through the two-way-looking sugar. When a wrapper genuinely needs first-keystroke-local behavior, Vue 3.5's defineModel({ local: true }) opts into local state with explicit synchronization — an opt-in, not the default, precisely because eager local copies are the drift bug this API was designed to avoid.
Typing and options mirror props:
const model = defineModel<number>({
required: true,
default: 0
})
required and default feed the same runtime prop config as defineProps — omitting the binding on a required model warns in dev; a default covers absence. TypeScript flows both ways: model.value is typed for the child, and the parent's v-model="count" is checked against the child's type under vue-tsc.
Modifiers become the second tuple element:
const [model, modifiers] = defineModel<string, 'trim' | 'capitalize'>()
function commit(raw: string) {
if (modifiers.value?.trim) raw = raw.trim()
if (modifiers.value?.capitalize) raw = raw[0].toUpperCase() + raw.slice(1)
model.value = raw
}
modifiers is a readonly ref of the modifier object ({ trim: true } or undefined), typed by the second generic — the typed successor to hand-reading modelValueModifiers.
For normalization at the binding boundary, the macro accepts get/set, reusing the writable-computed vocabulary in Writable Computed Values:
const cents = defineModel<number>({
get: (v) => v / 100, // prop (cents) → displayed (units)
set: (v) => Math.round(v * 100) // entered (units) → prop (cents)
})
Here the parent stores integer cents and the input works in decimal units; get/set define the translation once, at the component boundary, instead of in every consuming parent.
Two internal facts explain the macro's boundaries. defineModel compiles down to a call to the useModel composable — roughly useModel(__props, 'modelValue', options) — and useModel is exported, so a component written with the Options API, or a composable building a model channel outside <script setup>, can use it directly against an explicit props object. Second, like every compiler macro, defineModel() must run synchronously during setup; moving it into an event handler or a lazy callback fails at compile time, not at runtime.
The synthesized prop is real: it participates in attribute fallthrough, shows up in the Vue devtools prop panel, honors the required and default options, and — the detail worth testing for — stays the source of truth even when the child writes eagerly. Two defineModel() calls in one component create two independent channels, never a shared one, and each write emits only its own channel's update event; binding two fields at once therefore requires the argument form, which is the next chapter's subject.
One migration note: defineModel needs no import and no vue ^3.4 feature detection — it is a compiler macro, so on 3.3 and earlier you keep the manual pair. And a single-model component is only half the story; forms that bind several fields at once reach for argument suffixes — v-model:title, v-model:description — which the next chapter wires up.
Multiple Models, Named Bindings: v-model:title and the Suffix Convention
A profile editor needs two bindable fields on one composite component — a name and a display color — both under two-way binding. One v-model is not enough (it owns the modelValue slot alone), so v-model takes an argument: v-model:title. The compiler desugars it to the same prop/emit shape with the argument as the suffix:
<ProfileEditor v-model:title="name" v-model:color="color" />
<!-- expands to -->
<ProfileEditor
:title="name" @update:title="name = $event"
:color="color" @update:color="color = $event"
/>
Every model is an independent prop/update:x pair; the argument simply names the channel. On the child, defineModel accepts the same suffix:
<script setup lang="ts">
const title = defineModel<string>('title') // prop title, emit update:title
const color = defineModel<string>('color') // prop color, emit update:color
</script>
Each macro instance is a separate synchronized ref — the channel discipline from typed emits applies per model, and the parent may bind one, both, or neither (an unbound model just never updates).
The naming convention that keeps composite components legible: the argument is the semantic field name — v-model:title, v-model:query, v-model:startDate — never a second v-model:v1. Modifiers compose with arguments in the documented order, v-model:title.trim, and reach the child as the titleModifiers prop in the manual implementation or through the macro's modifier tuple.
Where this pattern earns its complexity is wrapping complex inputs behind one bindable interface. A date-range picker, a rating widget, an autocomplete — components too elaborate to be a bare <input> — become ordinary form citizens:
<DateRangePicker
v-model:from="filters.from"
v-model:to="filters.to"
:blocked-dates="holidays"
/>
The consuming template reads like a form field, while the child owns calendars, presets, and keyboard handling. Compare the alternative — exposing @from-changed, @to-changed, :from-value and friends — and the value of the convention is obvious: one uniform shape for every bindable component in the design system.
The manual form of multiple models is worth seeing once, because it shows the macro is convenience and nothing more:
<script setup lang="ts">
const props = defineProps<{ title: string; color: string }>()
const emit = defineEmits<{
'update:title': [value: string]
'update:color': [value: string]
}>()
</script>
Each emit pairs with its own parent listener; defineModel('title') and defineModel('color') in the same file are exactly these two channels, wrapped as synchronized refs.
Two naming rules keep the channels connectable. The argument is the contract verbatim: v-model:title pairs the prop title with the emit update:title, and a multi-word argument like v-model:startDate must be spelled the same camelCase way on both sides — that works as written in SFC templates, while in-DOM templates lowercase attributes and break camelCase arguments, which is why mixed kebab/camel spellings produce "the binding never connects" bugs. And keep arguments semantic — v-model:query, v-model:startDate — because a component whose bindings read v-model:v1, v-model:v2 has thrown away the self-documenting half of the convention. A composite that needs more than two or three models is usually telling you it wants plain props plus semantic events instead.
For debugging, keep the desugaring reflex: a composite component that "doesn't bind" is almost always channel confusion — v-model:start-date expecting a startDate prop that was declared startDateRange, a listener for update:startdate mismatched by case, or a parent binding v-model (the modelValue channel) on a component that only implements named channels. Expand the template by hand into prop/emit pairs and the broken link is visible in ten seconds. The convention's power — uniform, self-describing two-way bindings across a design system — is also its only rule: every channel is a named prop/emit pair, and the name is the contract. With components now fully composable — registered, prop-typed, event-driven, and bindable — the next topic opens the other half of composition: letting the parent supply markup through slots.
Quiz
1. What does <MyInput v-model="query" /> compile to, and what must MyInput implement?
2. const model = defineModel<string>(); the child runs model.value = 'hi', but the parent's handler rejects the value. What does model.value hold?
3. A parent writes <Editor v-model:title="name" v-model:color="color" />. Which prop/emit pairs does the child implement?
4. Your component receives v-model.trim on a manual (pre-defineModel) implementation. What happens, and where does trimming occur?
Flashcards
Question
What is the exact desugaring of v-model on a component, and how does it differ from a native input?
Click to reveal answer
Answer
<Child v-model="x" /> ≡ <Child :modelValue="x" @update:modelValue="x = $event" /> — component contract is modelValue/update:modelValue. Native inputs use value + input (checked + change for checkboxes). v-model on a component implementing value/input silently fails — the names are framework contract, not convention.
Question
What does defineModel() return, and what are its synchronization semantics?
Click to reveal answer
Answer
A writable ref backed by the implicit modelValue prop / update:modelValue emit pair (Vue 3.4+). Reading reflects the prop; writing emits upward — the prop stays the source of truth, so a parent that ignores the event reverts the write. Options: required/default like props; get/set for boundary normalization; modifiers via the [model, modifiers] tuple; 3.5 adds { local: true } for opt-in local state.
Question
How do multiple v-model bindings work?
Click to reveal answer
Answer
Each argument suffix names an independent channel: v-model:title → :title + @update:title, v-model:color → :color + @update:color. The child declares one defineModel('title') per channel (or manual prop/emit pairs), modifiers ride per-channel (titleModifiers), and any channel may be left unbound.
Question
Where do v-model modifiers on a custom component go, and who implements their behavior?
Click to reveal answer
Answer
To the child as data — modelValueModifiers prop ({ trim: true } / false) manually, or the readonly modifiers ref from defineModel's tuple. The framework implements .trim/.number/.lazy only for native elements; on components, behavior belongs in the child's update path — that's the extensibility point.
Question
Give the 10-second debug routine for a custom v-model that won't bind.
Click to reveal answer
Answer
Expand v-model by hand into prop/emit pairs and diff against the child: wrong channel (v-model vs v-model:name), prop name mismatch, an event-name case mismatch between the child's emit and the parent's listener, or a parent handler rejecting values (defineModel reverts by design). Every channel is a named prop/emit pair — the name is the contract.
Revision Notes
Key Takeaways
- 1. Component v-model is modelValue down and update:modelValue up — a named one-way round trip, distinct from the native value/input contract
- 2. defineModel collapses that pair into a writable ref whose value always settles to the parent's prop — the parent remains the source of truth
- 3. Argument suffixes (v-model:title) create independent named channels, each a separate prop/emit pair, letting composite components bind several fields
- 4. Modifiers arrive as data (modelValueModifiers / the modifier tuple); implementing them is the child's job, which is the flexibility point
- 5. get/set on defineModel puts unit and format normalization at the binding boundary so parents store clean data
Interview Tips
- • "What does v-model on a component actually do?" — give the exact desugaring (:modelValue + @update:modelValue), contrast with native value/input, then note defineModel generates the same pair
- • "Why does my defineModel write revert?" — the prop is the source of truth; assignment emits, and an ignoring/rejecting parent means the ref settles to the old prop — mention local: true (3.5) as the explicit opt-in for local-first
- • "Implement a multi-field editor with two-way bindings" — v-model:title / v-model:color channels, defineModel('title') per channel, each independent and optionally unbound
- • "How do v-model.trim modifiers work on a custom component?" — they arrive as modifier data; the child implements trimming in its update path — built-in modifier behavior is native-elements-only
Cheat Sheet
v-model on Components
Desugar (memorize):
<Child v-model="x" /> ≡ :modelValue="x" @update:modelValue="x = $event"
<Child v-model:title="t" /> ≡ :title="t" @update:title="t = $event"
native input = value/input · component = modelValue/update:modelValue · argument suffixes name extra channels.
Manual child: props modelValue (+ modelValueModifiers) · emit update:modelValue with the NORMALIZED value · inner input uses :value + @input (never local v-model).
defineModel (3.4+):
const [model, modifiers] = defineModel<string, 'trim'>({ default: '' })
model.value = commit(raw) // write → emit; ref settles to parent's prop (prop = source of truth)
const cents = defineModel<number>({ get: v => v/100, set: v => Math.round(v*100) })
3.5: defineModel({ local: true }) opt-in local-first sync.
Modifiers: data, not behavior — child decides (trim in update path); built-in .trim/.number/.lazy exist only for native elements.
Debug: expand to pairs, check channel name, prop name, case, and whether the parent handler rejects writes (revert is by design).