Vue 3.4's defineModel(): Two-Way Binding Without the Boilerplate
Vue 3.4's defineModel() macro collapses the modelValue prop + update:modelValue boilerplate into one line. Here's how it works, where it fits, and where it doesn't.
28 Jan 2026, 15:03 UTC

If you've built a custom input component in Vue 3, you know the ritual: declare a modelValue prop, declare an update:modelValue emit, write a computed with a getter and setter, and then—finally—bind it to your <input>. None of that code is about your component. It's plumbing. Since Vue 3.4, defineModel() collapses that plumbing into one line, and it's worth understanding exactly what it does and where it fits.
The pattern defineModel replaces
Vue's v-model on a component has always been syntax sugar: the parent passes a modelValue prop and listens for an update:modelValue event. The child was responsible for both halves, which meant a "simple" reusable text input looked like this:
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>This works, and it still works today. But every form component in your app repeats it, and the indirection makes typos in the event name a real (if rare) bug class.
What defineModel actually is
defineModel() is a compiler macro—like defineProps and defineEmits, it only exists inside <script setup> and disappears at compile time. It declares the prop and the event for you and returns a ref the child can read and write directly:
<script setup>
const model = defineModel()
</script>
<template>
<input v-model="model" />
</template>Writing to model.value emits update:modelValue; reading it reads the prop. The compiled output is the same prop-plus-event pair you'd write by hand, which has a useful consequence: interop is free. A parent can still bind with explicit :modelValue and @update:modelValue, and a defineModel child slots into codebases full of old-style components without anyone noticing.
A worked example: a reusable search box
Here's a realistic leaf component—a search input with a clear button—using both a default model and a named one:
<!-- SearchInput.vue -->
<script setup>
const query = defineModel({ default: '' })
const active = defineModel('active', { type: Boolean, default: false })
function clear() {
query.value = ''
}
</script>
<template>
<div class="search-box">
<input
v-model="query"
placeholder="Search..."
@focus="active = true"
@blur="active = false"
/>
<button v-if="query" @click="clear">Clear</button>
</div>
</template>The parent stays declarative:
<SearchInput v-model="query" v-model:active="searchFocused" />The named argument defineModel('active') maps to v-model:active on the parent—an active prop plus an update:active event. You can have as many named models as you need, each binding independently. Options like default, required, and validator pass through as the first argument's option object (or the second argument for named models).
One more capability worth knowing: if the parent uses modifiers (v-model.trim), the child can read them via the second return value and implement the behavior itself:
const [model, modifiers] = defineModel({
set(value) {
if (modifiers.trim) return value.trim()
return value
}
})The trade-off: implicit mutation
defineModel's convenience is also its hazard. When a child writes query.value = '', it's mutating parent state, and nothing in the child's template advertises that. In a small form component that's fine—two-way binding is the whole point. In a complex container component, implicit writes to parent state can make data flow genuinely hard to trace, and a code reviewer has to hunt for every assignment to the model ref.
A reasonable rule of thumb: use defineModel for form-like leaf components—inputs, toggles, date pickers, tag editors—where the component's job is editing one value. For containers that orchestrate several pieces of state, explicit props down and named events up (@submit, @remove) communicate intent better.
Also beware deep mutation. Passing an object via v-model and editing model.value.address.city in the child works, but it sidesteps the emit entirely and will surprise teammates who expect one-way data flow. Prefer replacing the object (model.value = { ...model.value, city } ) so the update is explicit.
Version requirements and how to verify
defineModel is stable in Vue 3.4 and newer. On 3.3 it existed behind an experimental flag; on 3.2 or earlier you need the manual pattern. Check your version in package.json, or at runtime:
import { version } from 'vue'
console.log(version) // needs to be >= 3.4.0Run that check anywhere in your app's setup code or a scratch component—no special permissions needed. Then verify behavior with a minimal pair: a child calling defineModel() bound to an input, and a parent rendering <Child v-model="text" /> alongside {{ text }}. Typing in the child should update the parent immediately. If you want to confirm the macro is doing what you expect, inspect the compiled SFC output in the Vue playground—you'll see the familiar modelValue prop and update:modelValue emit.
One caveat: type inference and modifier handling have been refined across minor releases, so if you're relying on advanced options, test against your installed version rather than assuming parity.
Closing
defineModel doesn't add new capability—it removes ceremony from a pattern Vue developers were already writing by hand, while staying fully compatible with the old style. If you're on Vue 3.4+, migrate your form components one at a time (the runtime behavior is identical, so there's no big-bang risk), keep explicit props and events for container components, and you'll end up with less code that says the same thing more clearly.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.