Vue 3.4's defineModel: The End of v-model Boilerplate in Script Setup
Vue 3.4's defineModel macro replaces the modelValue/update:modelValue boilerplate with a single two-way-bound ref. Here's how it works, where it shines, and where it hides too much.
23 Jun 2026, 00:32 UTC

Every Vue developer who has built a custom input component knows the ritual: declare a modelValue prop, emit update:modelValue, wire a computed getter/setter so the child can both read and write, and repeat the whole thing for every bound value. It works, but it's a dozen lines of ceremony for what is conceptually one sentence: "this component holds a value the parent owns."
Vue 3.4's defineModel() macro collapses that ritual into a single line. This post covers what it actually does, a worked example with named bindings and transformers, and where the convenience stops being worth it.
What defineModel actually compiles to
defineModel() is a compiler macro available inside <script setup>. It returns a ref that is automatically two-way bound: the child reads and writes it like any other ref, and Vue generates the modelValue prop declaration and the update:modelValue emit for you. Because it's compile-time sugar, the output is the same props/emits contract you'd write by hand — which means parents using the old explicit pattern, or a plain v-model, both keep working unchanged.
Two constraints follow from it being a macro. First, it only exists in <script setup>; Options API or plain setup() components still need the manual pattern. Second, your project needs Vue 3.4 or later — check with npm list vue before adopting it, since teams pinned to earlier 3.x releases get a compile error, not a graceful fallback.
A worked example: a reusable text input
Here's a leaf form control. The child:
<script setup>
const model = defineModel({ required: true })
</script>
<template>
<input v-model="model" />
</template>The parent binds it exactly as it would a native input:
<script setup>
import { ref } from 'vue'
const username = ref('')
</script>
<template>
<TextInput v-model="username" />
</template>That's the whole contract. The child can also write model.value = '' programmatically (say, from a "clear" button) and the parent's username updates. No props declaration, no emit registration, no computed bridge.
Named bindings and transformers
Where the old pattern got genuinely painful was multiple bound values. A post editor exposing both v-model:title and v-model:body used to require two props and two emit names. With the macro, each named binding is one call:
<script setup>
const title = defineModel('title', { default: 'Untitled' })
const body = defineModel('body', {
set(value) {
return value.replace(/\s+/g, ' ').trim()
}
})
</script>The options object accepts required, default, and get/set transformers that normalize values as they cross the component boundary — useful for coercing strings to numbers or collapsing whitespace, as above. One caveat: v-model modifiers like .trim are not applied automatically. If the parent writes v-model.trim="title", the child must opt into modifier handling through the options object; otherwise the modifier is silently ignored.
The trade-off: convenience vs. visible data flow
Two-way binding hides a write inside what looks like a read. In a small tree — parent form, child control — that's exactly what you want. In a deep tree, a defineModel ref passed down through several layers makes it hard to answer "who changed this value?" without a debugger. The macro is best reserved for leaf controls: custom inputs, pickers, sliders, toggles. For cross-component state that many components touch, an explicit store (Pinia) or named events keep the data flow legible.
There's a subtler gotcha with objects. If the bound value is an object and the child mutates a nested property — model.value.address.city = 'Oslo' — that mutates the parent's object by reference, with no emit involved. Teams relying on immutable update patterns for change tracking or undo history should either bind primitives or replace the whole object (model.value = { ...model.value, address: newAddress }) so the update flows through the emit.
Verify it before you commit
Three quick checks: confirm npm list vue reports 3.4 or higher; build the minimal input above and confirm child edits update parent state; and, if you're curious about the sugar, paste the SFC into the Vue playground and inspect the compiled output — you'll see the familiar modelValue prop and onUpdate:modelValue handler. If your team has older components using the manual pattern, leave them alone; they interoperate with defineModel children without changes, so adoption can be incremental, one reusable control at a time.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.