v-model between components — wiring two-way binding with defineModel
Chapter 4 put v-model on an <input>. The same directive works on your own components.
What it expands into differs, though. On a native element it expands into DOM properties and events; on a component it becomes the props-and-emit pair from chapter 6. This chapter looks at bundling that pair with defineModel, and at where the value actually sits.
What you'll learn in this chapter
- What a component's
v-modelexpands into - Count the props and emits
defineModelgenerates - Explain, with the conditions attached, whether the parent or the child owns the value
- Give a single component several
v-models - Distinguish the modifiers you do not have to implement yourself
v-model is a props-and-emit pair
The v-model the parent writes expands into a prop and an event handler.
<!-- what the parent writes -->
<TextField v-model="displayName" />
<!-- after expansion -->
<TextField
:modelValue="displayName"
@update:modelValue="$event => (displayName = $event)"
/>
So from the child's point of view, it receives a modelValue prop and emits update:modelValue. Before Vue 3.4, the child declared both of these by hand.
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const onInput = (event: Event) => {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>
<template>
<input :value="props.modelValue" @input="onInput" />
</template>
It works, but you are left with keeping the name in step in two places, plus the boilerplate of pulling the value out of the input event.
Bundling it with defineModel
From 3.4 onwards, use defineModel. The official guide recommends it too.
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>
<template>
<input v-model="model" />
</template>
What comes back is a ref. Read it and you get the value the parent bound; write to it and a notification goes to the parent. Being a ref, it can be handed straight to an <input>'s v-model. It achieves the same exchange as LegacyTextField.vue above in a single line of declaration.
The parent side is unchanged. It binds a ref as before.
<template>
<TextField v-model="displayName" />
<p>{{ displayName }}</p>
</template>
What grows after compilation
defineModel is a compiler macro like defineProps / defineEmits. It is not a function called at runtime; it is rewritten while <script setup> is processed. The TextField.vue above becomes the following (excerpted from the development-build output).
props: {
"modelValue": { type: String, ...{ required: true } },
"modelModifiers": {},
},
emits: ["update:modelValue"],
setup(__props, { expose: __expose }) {
const model = _useModel<string>(__props, "modelValue")
Counting them, that is two props and one emit. modelModifiers is always declared alongside modelValue in order to receive the modifiers covered later. Each call adds two more.
But props are fewer when the generated names collide. Only the default model's modifier prop is called modelModifiers rather than modelValueModifiers. So putting defineModel() and defineModel('model') side by side makes the modifier props collide. With defineModel('a') and defineModel('aModifiers'), the latter's model name collides with the former's modifier prop name. Either way you get three props.
Note that required: true has disappeared from the arguments to useModel and moved into the props declaration. Prop options are moved into the props declaration at compile time, and only the get / set covered below remain on the useModel call.
The type: String beside it was not moved there; it was added by inference from the type argument. It is for the development build's prop checking, so a production build gives "modelValue": { required: true }.
Types that include Boolean, however, survive into production too. The type is needed to decide whether an absent value becomes false, and whether "" reads as true. A Function type with options passed also survives, because the type decides whether a function default reads as "a value to call" or "a value to use as-is", so the call is made to keep the type when options are present. In every case the number of props and the emits are unchanged.
This sorting applies when the options are written directly as an object literal. Include a spread, as in { ...opts }, and no decomposition happens: what you passed appears in both the props declaration and the useModel call.
If opts is declared inside <script setup>, that form does not work. The props declaration is placed outside setup(), so the output refers to a local variable of <script setup> from there, and evaluating it raises a ReferenceError. An imported value, or one declared in a normal <script> block, is in module scope and works, but writing the options directly is the reliable route.
Passing only a type argument mixes undefined into the value, because the parent may not pass the prop. Adding required: true or a default removes it.
const model = defineModel<string>()
// ^? Ref<string | undefined>
const title = defineModel<string>('title', { required: true })
// ^? Ref<string>
Where the value lives
What useModel returns is a customRef. The value sits in the function's closure as localValue, and a synchronous watcher pushes prop changes into it.
watchSyncEffect(() => {
const propValue = props[camelizedName]
if (hasChanged(localValue, propValue)) {
localValue = propValue
trigger()
}
})
The write side takes a different route depending on whether the parent bound a v-model. The test is "did both the prop and the handler arrive?"
// allowing for notation differences, check that both the prop name and the handler name are there
const hasVModel = !!(rawProps && (name in rawProps || /* ... */) &&
(`onUpdate:${name}` in rawProps || /* ... */))
if (!hasVModel) {
localValue = value
trigger()
}
i.emit(`update:${name}`, emittedValue)
When both are present, nothing is written to localValue directly. The emitted value travels through the parent and comes back as a prop, at which point the watcher above reflects it. In other words, the parent is the owner in this case.
When the parent rejects the value
The parent being the owner means that if the parent rejects a value, the child's model does not move either.
<script setup lang="ts">
import { computed, ref } from 'vue'
import TextField from './TextField.vue'
const stored = ref('a')
const guarded = computed({
get: () => stored.value,
// do not accept input longer than three characters
set: (value: string) => {
if (value.length <= 3) stored.value = value
},
})
</script>
<template>
<TextField v-model="guarded" />
</template>
Type abcd and both stored and the child's model stay at a. The input field still shows abcd, though. Since model did not move, the child does not re-render and value is never written back.
The display corrects itself the next time the child re-renders. Typing a value the parent will accept brings them back into line, but an unrelated prop moving is enough on its own. The v-model implementation rewrites el.value immediately before an update, so characters you typed vanish because of a re-render for some other reason. If the child's input uses a default v-model, that write-back is suspended during IME composition, so nothing reverts mid-composition.
Rejecting input in the parent's setter walks into this mismatch. Accepting it and then validating, showing the error separately, is the more straightforward shape.
When the parent did not attach v-model
If either the prop or the handler is missing, writes go straight into localValue. So a component placed without a v-model behaves as an ordinary component holding its own state.
Place <TextField model-value="initial" /> and you can still type, with the value staying inside the child. Omit the prop as well from a child with required: true, though, and the development build warns Missing required prop and model becomes undefined. To leave room for local use, drop required.
Passing only the handler means the emit arrives while the local value also moves. Passing only the prop means the parent's state does not move, so the child's display drifts away from the prop. If the parent then changes the prop, the watcher kicks in and the child follows.
Passing default puts parent and child out of step
default looks convenient, but it means parent and child hold different values when the parent passes nothing.
<script setup lang="ts">
const model = defineModel<string>({ default: 'fallback' })
</script>
The parent's state stays undefined while the child's model is fallback. The official guide flags this as a warning too. Holding the initial value on the parent side is safer.
Naming them to have several
Pass a string as defineModel's first argument and the prop name and event name take that name. Call it more than once and a single component can carry several v-models.
<script setup lang="ts">
const firstName = defineModel<string>('firstName', { required: true })
const lastName = defineModel<string>('lastName', { required: true })
</script>
<template>
<input v-model="lastName" />
<input v-model="firstName" />
</template>
What gets declared is four props — firstName / firstNameModifiers / lastName / lastNameModifiers — and two emits, update:firstName / update:lastName.
The parent prepares two refs and binds them with an argumented v-model.
<template>
<NameFields v-model:first-name="first" v-model:last-name="last" />
</template>
Calling defineModel twice with the same name stops compilation with duplicate model name.
Only one side of the argument notation is converted
Write defineModel's argument in camelCase and the parent's argument reaches the child in either kebab-case or camelCase. How it arrives is not uniform, though. Look at the expansion of v-model:first-name.capitalize and some parts are converted while others are not.
{
"first-name": _ctx.first, // the prop name stays as written
"onUpdate:firstName": /* ... */, // the event name is camelized
"first-nameModifiers": { capitalize: true } // the modifier stays as written, plus Modifiers
}
That is where the mixed name first-nameModifiers comes from. The receiving side absorbs the notation differences, but the value and the modifiers look at different props.
The value prop is normalised to camelCase during resolution before being matched against the child's declaration. The emit that applies .trim / .number, on the other hand, looks at the raw props exactly as the parent passed them. A key such as first-nameModifiers is still there, so emit tries both camel and kebab spellings to find it. That route does not go through defineModel — which is why v-model:first-name.trim works even on a child written with nothing but defineProps and defineEmits.
Reading modifiers from defineModel's return value looks at the resolved props, so it matches on the camelCase side directly.
Both work, so kebab-case for the attribute and camelCase for defineModel's argument is enough. The official guide writes it with that combination.
Receiving modifiers
When the parent adds a modifier such as v-model.capitalize, the marker arrives on the <name>Modifiers prop. Destructure the return value and it comes second.
const [model, modifiers] = defineModel<string, 'capitalize'>()
if (modifiers.capitalize) {
// ...
}
The ref can be destructured because useModel's return value grows a Symbol.iterator. The first element is the ref itself and the second returns the modifiers object. The type is likewise an intersection of Ref and a two-element tuple, so both usages type-check. With no modifiers attached, it is an empty object.
What you do not have to implement yourself
.trim and .number work without the child writing anything. Inside emit, finding an event starting with update: makes it read the modifiers that arrived and transform the arguments.
const isModelListener = event.startsWith("update:")
const modifiers = isModelListener && getModelModifiers(props, event.slice(7))
if (modifiers) {
if (modifiers.trim) {
args = rawArgs.map((a) => isString(a) ? a.trim() : a)
}
if (modifiers.number) {
args = rawArgs.map(looseToNumber)
}
}
This layer is unrelated to whether you use defineModel. Even on a child written with defineEmits, like LegacyTextField.vue above, writing v-model.trim on the parent trims. On a child that does not declare modelModifiers, though, that prop passes through as an attribute. On a child with a single root element and inheritAttrs left at its default, modelmodifiers="[object Object]" lands on that element (how the destination is decided is the same as attribute inheritance in chapter 6).
There are only two branches here, trim and number. Every other modifier, .lazy included, only delivers a marker, so the child reads it and implements it if needed. Putting v-model.lazy on a component does not delay the update.
Putting these two together throws away the result of .trim. The .number branch rebuilds from the original arguments rather than the already-transformed args. Feed ' abc ' to v-model.trim.number and looseToNumber cannot read it as a number, so it returns the original string and the parent receives ' abc ' with the spaces intact. On a native element they are applied in order and you get 'abc'. The difference only shows for input that cannot be read as a number; ' 42 ' gives 42 either way.
Converting the value with set
Passing the get / set options lets you convert the value on read and write. get changes only what the child reads and does not touch the parent's state; set transforms the write. A modifier that folds full-width digits to half-width can be written like this.
<script setup lang="ts">
const [model, modifiers] = defineModel<string, 'halfwidth'>({
required: true,
set(value) {
return modifiers.halfwidth ? toHalfWidth(value) : value
},
})
const toHalfWidth = (value: string) =>
value.replace(/[0-9]/g, (char) =>
String.fromCharCode(char.charCodeAt(0) - 0xfee0),
)
</script>
<template>
<input v-model="model" inputmode="numeric" />
</template>
A parent writing v-model.halfwidth="postalCode" receives the converted value, and it comes back as a prop, so the input field displays half-width too.
modifiers can be referenced from inside set because get / set are not moved into the props declaration and remain on the useModel call. set is called at write time, and by then modifiers is already bound.
What gets converted is the emitted value. When the parent has not bound a v-model, the route that writes back to the local value carries the unconverted value. The same applies to .trim / .number: they do not ride that route. For usage that stays entirely within the child, neither modifiers nor set can be relied on.
Summary
- A component's
v-modelexpands into a prop and an event handler. The default names aremodelValueandupdate:modelValue defineModelis a macro available from 3.4 onwards. It returns a ref: reading gives the parent's value, writing notifies the parent- It generates two props and one emit: the model prop, and a prop to receive its modifiers. The modifier prop is named
<name>Modifiers, except that the default model getsmodelModifiers. Props are fewer when generated names collide - With options written as an object literal, prop options move into the props declaration and only
get/setremain on theuseModelcall. Including a spread stops that decomposition - A type argument alone gives
Ref<T | undefined>. Addingrequired: trueor adefaultgivesRef<T> - Who owns the value depends on whether the parent passes both the prop and the handler. With both, the parent owns it and the child's writes come back through the parent. With either missing, it behaves as the child's local value, and that route carries neither modifier transformation nor the
setconversion - When the parent rejects a value, the child's
modeldoes not move either. No re-render happens, so only the input field's display remains, reverting to the parent's value the next time the child re-renders (with a defaultv-model, the write-back is suspended during IME composition) - Pass a
defaultand, if the parent passes nothing, parent and child hold different values - Write
defineModel's argument in camelCase and the parent side works in either kebab-case or camelCase. The prop name and modifier name arrive as written, and only the event name is camelized - Receive modifiers by destructuring the return value.
.trimand.numberare applied on theemitside, so the child needs no implementation. Every other modifier,.lazyincluded, only delivers a marker - Putting
.trimand.numbertogether throws away the result of.trim. Input with spaces that cannot be read as a number keeps its spaces, so the result differs from writing the same thing on a native element
- Vue — Component v-model — what it expands into, arguments, and modifier handling
- Vue — defineModel() — the signature and types
- Vue — customRef() — the foundation
useModelbuilds on
What to read next
- Slots — inserting content into a child from the parent
- provide / inject and custom directives — passing values without going through props