Custom v-model components, multiple v-model bindings, v-model modifiers, the defineModel() macro, and the patterns that make form-heavy apps feel effortless. Most developers use 20% of what v-model can do.
Every Vue developer knows v-model. You use it on inputs, you know it’s shorthand for :modelValue and @update:modelValue, and you move on.
That’s the 20%.
The other 80% — custom components with multiple v-model bindings, v-model transformers, the defineModel() macro that eliminates boilerplate entirely, custom modifiers, v-model with objects, and the composable patterns that make complex forms maintainable — most developers never reach, because the basic usage is good enough until it suddenly isn’t.
This post covers all of it.
What v-model Actually Is
Before the advanced patterns, a precise understanding of what v-model compiles to — because every advanced pattern follows from this.
<!-- This: -->
<input v-model="searchQuery" />
<!-- Compiles to: -->
<input
:value="searchQuery"
@input="searchQuery = $event.target.value"
/>
For components (not native elements), v-model is:
<!-- This: -->
<MyInput v-model="searchQuery" />
<!-- Compiles to: -->
<MyInput
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
That’s it. v-model is always a prop + an event. The prop name defaults to modelValue. The event name defaults to update:modelValue. Both are configurable — and that configurability is where the power lives.
The Old Way vs defineModel()
Before Vue 3.4, implementing v-model on a custom component required boilerplate:
<!-- ✗ Old pattern — verbose, repetitive -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
Every custom input component needed the same four things: a modelValue prop, an update:modelValue emit, binding the prop to :value, and emitting on every change event. Four things, every time, for every component.
defineModel() — Vue 3.4+
defineModel() collapses all four into one line:
<!-- ✓ Modern pattern — defineModel() macro -->
<script setup lang="ts">
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>
defineModel() returns a ref that:
- Reads from the parent’s
modelValueprop when you access.value - Emits
update:modelValuewhen you assign to.value
You don’t write the prop. You don’t write the emit. You just use the ref.
defineModel() with Options
<script setup lang="ts">
// Required value — parent must pass something
const model = defineModel<string>({ required: true })
// With default — optional prop with a fallback
const model = defineModel<string>({ default: '' })
// With validation
const model = defineModel<number>({
get(value) {
return value ?? 0
},
set(value) {
return Math.max(0, Math.min(100, value)) // clamp to 0-100
},
})
</script>
The get and set options are transform functions — the getter transforms the value coming in from the parent, the setter transforms the value going out to the parent. This is the cleanest way to build range inputs, clamped inputs, and inputs that transform their values.
Named v-model Bindings
A component can have multiple v-model bindings — each binding a different piece of state. This is one of the most useful and underused v-model features.
Example: A Date Range Picker
<!-- Parent component -->
<template>
<DateRangePicker
v-model:startDate="filterStart"
v-model:endDate="filterEnd"
/>
</template>
<!-- DateRangePicker.vue — two named v-models -->
<script setup lang="ts">
const startDate = defineModel<string>('startDate')
const endDate = defineModel<string>('endDate')
</script>
<template>
<div class="date-range-picker">
<input type="date" v-model="startDate" />
<span>to</span>
<input type="date" v-model="endDate" />
</div>
</template>
The defineModel('startDate') call binds to v-model:startDate on the parent. The defineModel('endDate') call binds to v-model:endDate. Each is independently reactive.
Example: An Address Form Component
<!-- Parent -->
<AddressForm
v-model:street="address.street"
v-model:city="address.city"
v-model:postalCode="address.postalCode"
v-model:country="address.country"
/>
<!-- AddressForm.vue -->
<script setup lang="ts">
const street = defineModel<string>('street', { default: '' })
const city = defineModel<string>('city', { default: '' })
const postalCode = defineModel<string>('postalCode', { default: '' })
const country = defineModel<string>('country', { default: '' })
</script>
<template>
<div class="address-form">
<input v-model="street" placeholder="Street address" />
<input v-model="city" placeholder="City" />
<input v-model="postalCode" placeholder="Postal code" />
<select v-model="country">
<option v-for="c in countries" :key="c.code" :value="c.code">
{{ c.name }}
</option>
</select>
</div>
</template>
v-model with an Object (Single Binding for Complex State)
Instead of multiple named v-models, you can bind a single v-model to a structured object:
<!-- Parent — bind entire address object as one v-model -->
<AddressForm v-model="address" />
<!-- AddressForm.vue — receives and emits the full object -->
<script setup lang="ts">
interface Address {
street: string
city: string
postalCode: string
country: string
}
const model = defineModel<Address>({
default: () => ({ street: '', city: '', postalCode: '', country: '' })
})
function updateField<K extends keyof Address>(field: K, value: Address[K]) {
model.value = { ...model.value, [field]: value }
}
</script>
<template>
<div>
<input
:value="model.street"
@input="updateField('street', ($event.target as HTMLInputElement).value)"
/>
<!-- ... other fields -->
</div>
</template>
Custom v-model Modifiers
Vue’s built-in modifiers (.trim, .number, .lazy) handle common transformations. For custom transformations, you can define your own modifiers.
<!-- Parent — using a custom .capitalize modifier -->
<CustomInput v-model.capitalize="username" />
<!-- CustomInput.vue — handles the custom modifier -->
<script setup lang="ts">
const [model, modifiers] = defineModel<string>({
set(value) {
// Apply .capitalize modifier if it's present
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
},
})
</script>
<template>
<input v-model="model" />
</template>
defineModel returns a tuple when you destructure — the ref and the modifiers object. modifiers.capitalize is true if .capitalize was passed, false or undefined otherwise.
Multiple Modifiers
<!-- Applying multiple modifiers -->
<CustomInput v-model.trim.uppercase="query" />
<script setup lang="ts">
const [model, modifiers] = defineModel<string>({
set(value) {
let result = value
if (modifiers.trim) result = result.trim()
if (modifiers.uppercase) result = result.toUpperCase()
return result
},
})
</script>
Named Model with Modifiers
Modifiers work with named v-models too — the second argument to defineModel can carry modifier information:
<!-- Parent -->
<UserForm v-model:email.lowercase="user.email" />
<!-- UserForm.vue -->
<script setup lang="ts">
const [email, emailModifiers] = defineModel<string>('email', {
set(value) {
if (emailModifiers.lowercase) return value.toLowerCase()
return value
},
})
</script>
Building Real Components with defineModel()
A Proper Checkbox Group
Checkbox groups are where v-model gets genuinely useful for custom components — the parent has an array of selected values, and the component manages the array:
<!-- Parent -->
<CheckboxGroup
v-model="selectedPermissions"
:options="permissionOptions"
/>
<!-- CheckboxGroup.vue -->
<script setup lang="ts">
interface Option {
value: string
label: string
}
const props = defineProps<{ options: Option[] }>()
const model = defineModel<string[]>({ default: () => [] })
function toggle(value: string) {
const current = model.value
const index = current.indexOf(value)
if (index === -1) {
model.value = [...current, value]
} else {
model.value = current.filter((_, i) => i !== index)
}
}
function isChecked(value: string) {
return model.value.includes(value)
}
</script>
<template>
<div class="checkbox-group">
<label
v-for="option in options"
:key="option.value"
class="checkbox-option"
>
<input
type="checkbox"
:checked="isChecked(option.value)"
@change="toggle(option.value)"
/>
{{ option.label }}
</label>
</div>
</template>
A Rich Select Component
<!-- Parent -->
<RichSelect
v-model="selectedUser"
:options="users"
option-label="name"
option-value="id"
/>
<!-- RichSelect.vue -->
<script setup lang="ts">
const props = defineProps<{
options: Record<string, unknown>[]
optionLabel: string
optionValue: string
}>()
const model = defineModel<unknown>()
const isOpen = ref(false)
const selectedOption = computed(() =>
props.options.find(o => o[props.optionValue] === model.value)
)
function select(option: Record<string, unknown>) {
model.value = option[props.optionValue]
isOpen.value = false
}
</script>
<template>
<div class="rich-select" @click="isOpen = !isOpen">
<div class="rich-select__trigger">
{{ selectedOption?.[optionLabel] ?? 'Select…' }}
</div>
<ul v-if="isOpen" class="rich-select__dropdown">
<li
v-for="option in options"
:key="String(option[optionValue])"
:class="{ 'is-selected': option[optionValue] === model }"
@click.stop="select(option)"
>
{{ option[optionLabel] }}
</li>
</ul>
</div>
</template>
A Toggle Switch
<!-- Parent -->
<ToggleSwitch v-model="emailNotifications" label="Email Notifications" />
<!-- ToggleSwitch.vue -->
<script setup lang="ts">
const props = defineProps<{ label?: string }>()
const model = defineModel<boolean>({ default: false })
const id = useId() // Vue 3.5+ — generates a unique ID
</script>
<template>
<label :for="id" class="toggle-switch">
<input
:id="id"
type="checkbox"
:checked="model"
@change="model = ($event.target as HTMLInputElement).checked"
class="toggle-switch__input"
/>
<span class="toggle-switch__track" />
<span v-if="label" class="toggle-switch__label">{{ label }}</span>
</label>
</template>
v-model Transform Patterns
The get/set options on defineModel() are a clean way to build input components that transform their values transparently to the parent.
Cents-to-Rupees Input
Parent stores value in paise, input shows and accepts rupees:
<!-- Parent stores paise internally -->
<MoneyInput v-model="product.priceInPaise" />
<!-- MoneyInput.vue — transforms between paise and rupees -->
<script setup lang="ts">
const model = defineModel<number>({
get(paise) {
// Convert paise → rupees for display
return paise != null ? paise / 100 : 0
},
set(rupees) {
// Convert rupees → paise for storage
return Math.round((rupees ?? 0) * 100)
},
})
</script>
<template>
<div class="money-input">
<span class="currency-symbol">₹</span>
<input
type="number"
v-model.number="model"
step="0.01"
min="0"
/>
</div>
</template>
Masked Phone Input
<script setup lang="ts">
// Parent sees: '9876543210' (raw digits)
// Input shows: '98765 43210' (formatted)
const model = defineModel<string>({
get(value) {
if (!value) return ''
const digits = value.replace(/\D/g, '')
if (digits.length <= 5) return digits
return `${digits.slice(0, 5)} ${digits.slice(5, 10)}`
},
set(value) {
// Store only digits
return value.replace(/\D/g, '').slice(0, 10)
},
})
</script>
Null-to-Empty-String and Back
<script setup lang="ts">
// Parent might pass null; input should show empty string
const model = defineModel<string | null>({
get(value) {
return value ?? ''
},
set(value) {
return value === '' ? null : value
},
})
</script>
v-model in Deeply Nested Components
The hardest v-model challenge: a form field three or four components deep that needs to bind back to state at the top. Prop drilling and emit chains become unmanageable.
The inject/provide v-model Pattern
<!-- FormProvider.vue — provides form state to all descendants -->
<script setup lang="ts">
interface FormContext {
values: Record<string, unknown>
setValue: (field: string, value: unknown) => void
}
const values = reactive<Record<string, unknown>>({
name: '',
email: '',
phone: '',
})
function setValue(field: string, value: unknown) {
values[field] = value
}
provide<FormContext>('form', { values, setValue })
defineExpose({ values })
</script>
<template>
<form><slot /></form>
</template>
<!-- FormField.vue — consumes the form context -->
<script setup lang="ts">
const props = defineProps<{ name: string }>()
const form = inject<FormContext>('form')!
const model = computed({
get: () => form.values[props.name] as string,
set: (value) => form.setValue(props.name, value),
})
</script>
<template>
<input v-model="model" />
</template>
<!-- Usage — FormField can be nested anywhere under FormProvider -->
<FormProvider ref="formRef">
<div class="form-section">
<div class="nested-component">
<AnotherComponent>
<!-- FormField binds directly to FormProvider state — no prop drilling -->
<FormField name="name" />
<FormField name="email" />
<FormField name="phone" />
</AnotherComponent>
</div>
</div>
</FormProvider>
Wrapping Third-Party Inputs
Many third-party components (date pickers, rich text editors, file uploaders) don’t use the standard modelValue/update:modelValue convention. Wrapping them in a defineModel() component gives you a consistent v-model API throughout your application:
<!-- DatePickerWrapper.vue — wraps a third-party picker with consistent v-model -->
<script setup lang="ts">
import ThirdPartyDatePicker from 'some-datepicker-library'
const model = defineModel<string>()
// The library uses 'value' and 'on-change' instead of the Vue convention
function handleChange(date: Date) {
model.value = date.toISOString().split('T')[0] // normalise to YYYY-MM-DD
}
const parsedDate = computed(() =>
model.value ? new Date(model.value) : null
)
</script>
<template>
<ThirdPartyDatePicker
:value="parsedDate"
@on-change="handleChange"
/>
</template>
<!-- Now used with standard Vue v-model syntax -->
<DatePickerWrapper v-model="user.birthDate" />
The useVModel Composable (VueUse)
For the common pattern of syncing a prop back to the parent, @vueuse/core‘s useVModel provides the same capability as defineModel() in situations where you need more explicit control:
npm install @vueuse/core
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
const props = defineProps<{
modelValue: string
firstName: string
lastName: string
}>()
const emit = defineEmits(['update:modelValue', 'update:firstName', 'update:lastName'])
// Each useVModel call creates a writable ref that emits on assignment
const model = useVModel(props, 'modelValue', emit)
const firstName = useVModel(props, 'firstName', emit)
const lastName = useVModel(props, 'lastName', emit)
</script>
In 2026, defineModel() is the preferred approach for new components — but useVModel remains useful for legacy components, libraries that must support Vue 3.3 and below, and complex scenarios with custom event names.
The Complete v-model Cheat Sheet
Basic v-model on a component:
<MyInput v-model="value" />
→ prop: modelValue, event: update:modelValue
Named v-model:
<MyForm v-model:title="form.title" v-model:body="form.body" />
→ props: title, body; events: update:title, update:body
v-model with modifier:
<MyInput v-model.trim="value" />
→ prop: modelValue, event: update:modelValue, modifiers: { trim: true }
Named v-model with modifier:
<MyInput v-model:email.lowercase="value" />
→ prop: email, event: update:email, modifiers: { lowercase: true }
defineModel() signatures:
const model = defineModel<string>()
const model = defineModel<string>('title')
const model = defineModel<string>({ required: true })
const model = defineModel<string>({ default: '' })
const model = defineModel<string>({ get(v) { ... }, set(v) { ... } })
const [model, modifiers] = defineModel<string>()
Native element v-model modifiers:
v-model.trim → trims whitespace
v-model.number → converts to number
v-model.lazy → syncs on change event (not input)
Final Thoughts
v-model is one of Vue’s most elegant features — and one of its most underused. The transition from “bind an input to a ref” to “build a consistent form abstraction layer” is entirely achievable with the patterns in this post, without any external library beyond Vue itself.
The patterns that pay the most dividends in real applications:
defineModel() with get/set transforms — stops boilerplate and makes value transformation declarative. A MoneyInput, a phone number input, a slug-generating text field — all expressed as simple transform functions.
Multiple named v-models — replaces prop drilling for complex form components. An AddressForm that takes v-model:street, v-model:city, v-model:country is more expressive and more maintainable than threading those fields individually.
The provide/inject form pattern — the right answer for deeply nested forms. Eliminates emit chains and prop drilling across component boundaries.
Wrapping third-party inputs — a consistent internal API regardless of what the underlying library expects. The rest of your application speaks standard Vue v-model; the wrapper handles the translation.
Build your form components once using these patterns. Every form in your application becomes a composition of reliable, focused pieces — and the difference between building a simple search bar and a complex multi-step checkout form is the same set of tools used at different scales.
