Two-way binding without props and emit boilerplate
Since Vue 3.4 (and further refined in Vue 3.5), the defineModel compiler macro takes over all the wiring that used to require a props declaration, a matching emits entry, and a manual emit('update:modelValue', value) call. Any component that wants to support v-model now needs a single line that behaves like a regular ref while automatically staying in sync in both directions.
Table of Contents
- 1. The problem: v-model before defineModel
- 2. The basic syntax of defineModel
- 3. Multiple named model bindings on one component
- 4. Reading v-model modifiers
- 5. Typing with TypeScript
- 6. Default values and required models
- 7. Local mutation and when it reaches the parent
- 8. Migrating existing components
- 9. Common mistakes and pitfalls
- 10. Summary
- 11. FAQ
1. The problem: v-model before defineModel
Before defineModel existed, any component that wanted to support v-model had to coordinate three separate pieces: a props declaration for modelValue, a matching emits entry for update:modelValue, and some place in the template or script where that exact event was fired manually. This was not a complicated pattern on its own, but it was repetitive, and once a component needed several simultaneous v-model bindings, the boilerplate multiplied quickly because every named binding required its own props and emit pair.
What made this especially awkward was that you could never write to the value directly from the template. You either had to build a computed property with an explicit getter and setter that internally called emit, or write emit('update:modelValue', newValue) by hand on every change. Both approaches worked reliably, but they spread the logic across several places in the component and made it harder for new team members to trace where a value actually came from and where it went when written to.
<!-- Before Vue 3.4: the classic v-model pattern -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
function onInput(event: Event) {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value)
}
</script>
<template>
<input :value="props.modelValue" @input="onInput" />
</template>
2. The basic syntax of defineModel
With defineModel, the same pattern collapses into a single call that returns a ref. Reading from this ref returns the current value of the prop, and writing to it automatically fires the matching update event, without ever calling emit yourself. In the template, this ref can be bound directly with v-model to a native input element, as if it were local, component internal state, even though it actually originates in the parent and flows back there again.
Under the hood the compiler still registers a modelValue prop and an update:modelValue event, so the underlying mechanism has not fundamentally changed. What has changed is the surface you work with as a developer: instead of keeping two separate concepts in mind, reading a prop and writing an event, there is now a single reactive object that behaves like a normal ref in both directions, which makes it correspondingly easy to test and pass around.
<!-- From Vue 3.4 onward: defineModel -->
<script setup lang="ts">
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>
3. Multiple named model bindings on one component
As soon as a component needs to synchronize more than one value with its parent, for example a form field that manages both a title and an active flag, the named variant of defineModel comes into play. Instead of calling defineModel() without arguments, you call defineModel('title') and defineModel('active'), and the parent then binds v-model:title and v-model:active to the matching props. Each of these bindings internally creates its own props and emit pair again, but none of that is visible anymore in the child component's code.
The advantage over the old pattern is especially clear here, because previously every additional named binding required another props declaration, another emits entry, and another manual emit call site. With defineModel, the cost per additional model stays at a single line, and the names of the models are visible exactly where they are used, which makes the component noticeably easier to read as a whole.
4. Reading v-model modifiers
Vue has built in v-model modifiers such as .trim, .number, or .lazy that are applied automatically on native elements. For custom components built with defineModel, you can define your own modifiers and react to them in the script by destructuring the second element of defineModel's return value. That second element is an object whose keys correspond to the modifiers the parent set, so you can check whether capitalize was set and transform the value accordingly before writing it.
This capability is used less often in practice than the plain two-way binding, but it becomes genuinely useful when a reusable component should behave slightly differently across different call sites without introducing an extra prop for every variation. A form field that should force uppercase letters or trim surrounding whitespace depending on where it is used can configure that logic directly at the v-model call site in the parent, instead of adding another explicit prop for it.
5. Typing with TypeScript
Like defineProps, defineModel supports a generic type parameter, so you can specify the model's type directly at the macro, for example defineModel
Typing works the same way for named models, except the name is passed as the first argument alongside the type parameter, for example defineModel
6. Default values and required models
Just as with defineProps, you can pass defineModel an options object where default and required can be set. A model with a default behaves sensibly even when the parent does not bind v-model at all, which is particularly useful for reusable UI building blocks that need to work both controlled and uncontrolled.
Setting required to true instead makes it explicit that the component does not make sense without a bound v-model, and TypeScript can verify that expectation wherever the component is used. In practice it is usually best to lean toward required for form fields and similar controls, while optional extras such as a collapsible panel are often better served by a sensible default, since the component then shows predictable behavior even without an explicit binding.
7. Local mutation and when it reaches the parent
A common misunderstanding is assuming that a direct assignment to the ref returned by defineModel reaches the parent synchronously and instantly. In reality it works exactly like any other reactive prop coupling in Vue: the assignment internally fires the update event, and Vue processes that event within the same tick, so the new value is consistent in both the child and the parent by the next render cycle.
It is important to note that the local variable in the child component does not exist independently of the parent, but stays coupled to the parent's state as long as the parent binds v-model. If the parent instead binds a fixed literal value to the prop, the model behaves like a normal, unsynchronized prop, and changes in the child only take effect locally, which is worth remembering when debugging changes that seemingly never reach the parent.
8. Migrating existing components
Migrating an existing component from the old pattern to defineModel is, in most cases, a matter of deleting code rather than restructuring it. You remove the relevant prop from defineProps, the matching entry from defineEmits, and every place where emit('update:modelValue', ...) was called manually, and replace all of that with a single defineModel line. In the template, every place that previously read props.modelValue is replaced with model.value, and every manual emit call site becomes a direct assignment to model.value.
One thing to watch out for during migration is that defineModel requires at least Vue 3.4, and the compiler needs to be able to translate the macro accordingly. In projects still running an older Vue version or older build tooling, defineModel simply will not work until a version upgrade is carried out first. It is worth migrating component by component and manually clicking through the affected forms after each step to make sure behavior has not changed along the way.
9. Common mistakes and pitfalls
A common mistake is calling defineModel again inside a function or a watch callback, assuming this creates an additional, dynamically named model. Like defineProps and defineEmits, defineModel is a compiler macro and may only appear at the top level of script setup, never conditionally and never inside a loop, because the compiler needs to statically analyze the call in order to register the matching props and events.
A second frequent pitfall is treating the returned ref as a plain read only value in the template while continuing to write changes through a manual emit call, effectively mixing the old and new pattern. This technically still works, since both paths ultimately fire the same event, but it leaves the code inconsistent and forfeits exactly the readability gain defineModel was meant to provide. Anyone adopting defineModel should consistently read and write only through the returned ref and remove any leftover manual emit calls for the same event.
| Aspect | Before defineModel (Vue < 3.4) | With defineModel (Vue >= 3.4) | Note |
|---|---|---|---|
| Props declaration | props: { modelValue: String } | const model = defineModel() | No separate props declaration needed anymore |
| Event emission | emit('update:modelValue', value) | model.value = value | Behaves like a regular ref |
| Named bindings | Own props/emit pair per name | defineModel('title') | Any number of named models possible |
| TypeScript | defineProps<{ modelValue: string }>() | defineModel |
Generic typing directly at the macro |
| Template readability | Read props.modelValue, write via emit | Read and write model.value | One reactive object instead of two separate concepts |
Mironsoft
Vue architecture, Composition API, and Nuxt performance
Vue applications that don't get more complicated with every feature?
We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.
Architecture Review
Checking composables, state management, and component structure for maintainability.
Performance Audit
Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.
Nuxt Integration
Building robust, type-safe SSR/SSG setup and API integration.
10. Summary
Vue defineModel at a glance
Available since
Stable since Vue 3.4, further refined in Vue 3.5
Core benefit
No manual props and emit pair needed for v-model anymore
Multiple bindings
Any number of named models via defineModel('name')
TypeScript
Generic typing possible directly at the macro