Back to Blogs

Vue 3.4's defineModel: The v-model Boilerplate Is Gone

The Boilerplate I Stopped Tolerating

Every custom form component I've ever written in Vue 3 started the same way.

I need a CustomInput. It accepts a value from the parent and emits changes back. That's it. Here's what the setup looked like before Vue 3.4:

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const value = computed({
  get: () => props.modelValue,
  set: (v) => emit('update:modelValue', v)
})
</script>

<template>
  <input v-model="value" />
</template>

Six lines of setup, every single time. The computed getter/setter exists solely to bridge the prop-to-emit gap. It does nothing else. You can't use props.modelValue directly in v-model because props are read-only. You can't use the emit directly in v-model because it's not a setter. So you write the computed. Every time.

I wrote that pattern in a dozen components across one project. By the end I could type it without thinking, which is exactly when you know something should be automated.


What defineModel Does

Vue 3.4 introduced the defineModel macro. Here's the same component:

<script setup lang="ts">
const model = defineModel<string>()
</script>

<template>
  <input v-model="model" />
</template>

One line. model is a writable ref — you can read it, you can assign to it, and it automatically handles the prop declaration and emit under the hood.

When the parent passes :modelValue="someString", model.value equals someString. When the component assigns model.value = newString, it fires update:modelValue with the new value. The parent's binding updates.

The parent still uses v-model the same way. The behavior is identical to the verbose version. You're just not writing the plumbing yourself.

defineModel is a compiler macro — the same category as defineProps and defineEmits. At build time, the Vue compiler expands it into the prop + emit pattern. There's no runtime overhead and no new API surface on the component. It's strictly a developer experience change.


Multiple v-models

This is where the old pattern became genuinely painful.

Vue 3 has supported multiple named v-models since launch — a component can bind v-model:title and v-model:content simultaneously. But the implementation meant one prop and one emit per binding, then a separate computed getter/setter for each. A component with four named v-models had sixteen lines of boilerplate before you got to any actual logic.

With defineModel:

<script setup lang="ts">
const title = defineModel<string>('title')
const content = defineModel<string>('content')
</script>

<template>
  <input v-model="title" />
  <textarea v-model="content" />
</template>

The string argument is the v-model name. The parent binds like this:

<BlogEditor v-model:title="post.title" v-model:content="post.content" />

I ran into this directly while building the annotation toolbar for my PDF editor. The toolbar component has inputs for font size, color, opacity, and line width — each a named v-model on the same parent component. Before 3.4 there was a computed for each one and an ever-growing defineEmits call. Now it's one defineModel call per field, and the component reads like the component it actually is rather than a boilerplate collection.


Default Values and Validators

defineModel accepts an options object as the second argument, same shape as defineProps option syntax:

<script setup lang="ts">
const fontSize = defineModel<number>('fontSize', {
  default: 16,
  validator: (v: number) => v >= 8 && v <= 72
})
</script>

The validator runs when the parent updates the binding. If it returns false, Vue warns in development. The default fills in when the parent doesn't pass the prop at all.

One caveat: if the prop is optional and has no default, the type becomes string | undefined, not string. The macro infers this correctly, but TypeScript will surface it — you'll get type errors if you pass model.value somewhere expecting a plain string. Add a default or guard with model.value ?? '' at the call site.


Intercepting Values with get and set

The options object also accepts get and set functions for transforming values:

<script setup lang="ts">
const [model, modifiers] = defineModel<string>({
  set(value) {
    if (modifiers.trim) return value.trim()
    if (modifiers.uppercase) return value.toUpperCase()
    return value
  }
})
</script>

When you destructure defineModel, the second element is the modifiers object. This replaces the transformer logic you'd otherwise stuff into a computed setter. It also handles the .trim and .number built-in modifiers correctly without any extra code, since the compiler emits the right behavior for those automatically.

The get function intercepts reads — useful if you want the parent's stored value to differ from what the input actually displays. I haven't needed it often, but it's there when you do.


TypeScript

defineModel<string>() types the returned ref as Ref<string>. The generic is optional — without it, the ref is typed as Ref<any>, which you don't want in a TypeScript project.

For a v-model that can be absent from the parent, use a union: defineModel<string | undefined>(). The ref will be undefined when the parent doesn't bind anything, and Vue won't warn about the missing prop.

There's currently no required: true equivalent in defineModel. If you want the binding to be mandatory, a prop validator that rejects undefined is the closest option:

<script setup lang="ts">
const model = defineModel<string>({
  required: true
})
</script>

Vue 3.4+ supports required in the options object. It does what you'd expect — development warning if the parent doesn't pass the binding.


When the Old Pattern Still Makes Sense

Almost never in new code targeting Vue 3.4+. But a few situations come up:

Library authoring: If you're publishing a component library that targets Vue 3.3 and earlier, defineModel isn't available. You can use unplugin-vue-definemodel to backport it, or stay with the manual pattern.

Non-macro contexts: defineModel only works inside <script setup>. In options API components or composables, you're still using props and emits manually. This isn't a limitation in practice since the components that need v-model are almost always <script setup> components.

Reading a prop without binding back: If a child only needs to read a parent value without ever emitting, a regular prop is cleaner than a full defineModel. Don't reach for the macro when you don't need the write direction.


Compatibility

defineModel requires Vue 3.4+. Nuxt 4 ships with Vue 3.4+, so if you're on a current Nuxt project — including the Nuxt 4 app structure with the app/ directory — you're good to go with no configuration changes.

If you're upgrading from Nuxt 3 with an older Vue pin, check your package.json. Vue 3.4 was released in December 2023. Anything past that supports the macro.


The Short Version

defineModel() replaces the modelValue prop + update:modelValue emit + computed getter/setter pattern with a single writable ref. Multiple named v-models go from sixteen lines of plumbing to one line each. The get and set options handle value transformation without extra computeds. TypeScript support is first-class.

It makes components read like components instead of infrastructure. I've stopped thinking about v-model setup at all, which is where you want to end up.

Enjoyed this?

Share it with your network