Vue.js 61 🟢 Form Input Directives Customization and v-model Modifiers Deep Dive
v-model is Vue’s two-way binding directive, and on the surface it looks like magic: a single attribute synchronizes a form input with a reactive variable. Under the hood, it is a compile-time transformation that expands into a prop binding and an event listener. On a native <input>, v-model="text" becomes :value="text" plus @input="text = $event.target.value". On a custom component, the transformation changes: v-model="text" becomes :model-value="text" plus @update:model-value="text = $event" . This distinction—native elements use value/input, custom components use modelValue/update:modelValue—is the foundation for everything else in this chapter.
The modifiers—.trim, .number, .lazy—are not part of the directive itself. They are flags that the compiler passes to the generated code, and on a custom component, they arrive as a prop called modelModifiers . The component must read that prop and apply the transformation manually. This is the part that catches most developers: writing v-model.trim="name" on a custom input does not automatically trim the value. The modifier is delivered, but it is the component’s responsibility to honor it. This chapter covers the compile expansion, the built-in modifiers, the custom modifier mechanism, and the defineModel macro that simplifies the whole contract.
Key point: On native inputs, v-model expands to :value and @input (or :checked and @change for checkboxes/radios). On custom components, it expands to :modelValue and @update:modelValue. Built-in modifiers (.trim, .number, .lazy) work automatically on native inputs but must be manually applied in custom components via the modelModifiers prop (or the modifiers object returned by defineModel).
Why the compile expansion matters
The Vue 2 to Vue 3 migration problem. In Vue 2, custom components used value as the prop and input as the event. Vue 3 changed the defaults to modelValue and update:modelValue . A component that still declares props: ['value'] and emits input will silently fail to bind with v-model in Vue 3. The parent’s value will not update, and there is no error—the component simply does not participate in the binding. This is the single most common migration error, and it is resolved by understanding what the compiler generates.
The prop mutation problem. A child component cannot mutate its modelValue prop directly. Vue enforces one-way data flow: props are read-only. If the child writes props.modelValue = newValue, Vue warns at runtime, and the parent’s value does not change . The only correct path is to emit update:modelValue. The defineModel macro (Vue 3.4+) wraps this contract into a writable ref, but under the hood it still uses the same prop and event.
The modifier delivery problem. On a native input, the compiler knows how to apply .trim and .number because it generates the code inline. On a custom component, the compiler cannot know what the component does with its value. So it passes the modifiers as a prop—modelModifiers for the default v-model, and <name>Modifiers for a named model like v-model:title . The component reads the prop and decides how to apply the transformation. If the component ignores the prop, the modifier is silently ineffective.
The multiple binding problem. Vue 3 supports multiple v-model bindings on the same component through arguments: v-model:firstName and v-model:lastName . Each argument maps to its own prop (firstName, lastName) and its own event (update:firstName, update:lastName). This replaced Vue 2’s .sync modifier and the model component option. The defineModel macro makes this concise: const firstName = defineModel('firstName') and const lastName = defineModel('lastName') create the two bindings in two lines.
a. Built-in modifiers: .trim, .number, .lazy
Vue provides three built-in modifiers for v-model. On native inputs, they work automatically. On custom components, they are delivered as flags and must be applied manually.
<!-- Native input: .trim is applied automatically -->
<input v-model.trim="email" />
<!-- Native input: .number casts the string to a number -->
<input v-model.number="age" type="number" />
<!-- Native input: .lazy syncs on change instead of input -->
<input v-model.lazy="message" />
The .trim modifier strips leading and trailing whitespace from the value before assigning it to the bound variable. The .number modifier casts the input string to a number. An <input> always returns a string from its value property; without .number, age would be the string "30", not the number 30 . The .lazy modifier changes the event from input (every keystroke) to change (when the field loses focus or the user presses Enter).
Modifiers can be chained: v-model.trim.lazy="username" applies both. The order of application is determined by the compiler, but the practical result is that the value is trimmed and synced on change.
On a custom component, the built-in modifiers do not apply automatically. The component receives modelModifiers as a prop, and it must check the flags:
<script setup>
const props = defineProps({
modelValue: String,
// modelModifiers is injected by Vue when the parent uses v-model.trim
modelModifiers: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['update:modelValue'])
function onInput(event) {
let value = event.target.value
// Check the modifier flag and apply the transformation manually
if (props.modelModifiers.trim) {
value = value.trim()
}
// Emit the transformed value to the parent
emit('update:modelValue', value)
}
</script>
<template>
<input :value="modelValue" @input="onInput" />
</template>
The modelModifiers prop is an object with the modifier names as keys and true as values. v-model.trim produces { trim: true }. The component checks the key and applies the transformation before emitting. Without this check, the modifier is delivered but ignored.
b. Custom modifiers and the defineModel macro
Vue 3 allows custom modifiers: v-model.capitalize="name". The component receives modelModifiers.capitalize and decides what to do with it. The defineModel macro (Vue 3.4+) makes this concise by returning a tuple of the model ref and the modifiers object.
<!-- Parent: v-model.capitalize -->
<MyInput v-model.capitalize="name" />
<!-- Child: defineModel with the set option -->
<script setup>
// defineModel returns [model, modifiers]
// model is a writable ref; modifiers is the object of modifier flags
const [model, modifiers] = defineModel({
set(value) {
// Apply the capitalize modifier in the setter
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
},
})
</script>
<template>
<!-- Bind the model ref directly to the native input -->
<input v-model="model" />
</template>
The set option is the transformation hook. When the native input emits a new value, the set function runs, applies the modifier logic, and returns the transformed value. The model ref is then updated, and the parent’s value is synchronized . The get option can transform the value on the way in, which is useful for formatting the displayed value without changing the underlying data.
For multiple named models, each defineModel call returns its own modifiers. The naming rule is: the default v-model uses modelModifiers, and a named model v-model:title uses titleModifiers . This is a common source of silent failures. If the component reads modelModifiers but the parent used v-model:title.capitalize, the modifier is under titleModifiers, and the component’s check fails.
<script setup>
// Named model with its own modifiers
const title = defineModel('title', {
set(value) {
// titleModifiers is the correct prop name for v-model:title
// Access it through the second element of the tuple if needed
return value
},
})
// If you need the modifiers object for a named model:
const [titleModel, titleModifiers] = defineModel('title')
</script>
For projects on Vue 3.2 or 3.3 (before defineModel stabilized), the manual approach is defineProps with modelModifiers and defineEmits with update:modelValue. The type definitions are more verbose, but the runtime behavior is identical .
c. The defineModel contract and the pre-3.4 alternative
defineModel is a compiler macro. It does not exist at runtime. The compiler replaces it with a modelValue prop declaration, an update:modelValue emit declaration, and a writable ref whose get returns the prop and whose set emits the event . This is why the macro only works in <script setup> and why it requires Vue 3.4 or later.
The macro accepts prop options. defineModel({ required: true }) makes the model required. defineModel({ default: 0 }) provides a default value. The default value caveat is important: if the parent does not provide a value and the child has a default, the parent’s ref is undefined while the child’s model is the default. This de-synchronization is by design—the default is local to the child, and the parent’s state is not automatically updated .
The manual equivalent, for projects that cannot use defineModel:
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: String,
modelModifiers: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:modelValue'])
// A writable computed ref that mirrors the prop and emits on set
const model = computed({
get: () => props.modelValue,
set: (value) => {
// Apply modifiers before emitting
if (props.modelModifiers.trim) {
value = value.trim()
}
emit('update:modelValue', value)
},
})
</script>
<template>
<input v-model="model" />
</template>
The computed with a get and set is the pre-3.4 mechanism for a writable ref that is backed by a prop. The get returns the prop value, and the set emits the update event. The modifiers are applied in the set before the emit. This is the pattern that defineModel replaces, and it is still the correct pattern for older projects .
Complete Example Session
The following session builds a CapitalizedInput component that accepts v-model.capitalize and applies the modifier through defineModel, then shows the manual equivalent for pre-3.4 projects.
<!-- ============================================
PART 1: THE CAPITALIZED INPUT (defineModel)
============================================ -->
<!-- CapitalizedInput.vue -->
<script setup>
// defineModel returns the model ref and the modifiers object
// The set option applies the capitalize modifier before emitting
const [model, modifiers] = defineModel({
set(value) {
// If the parent used v-model.capitalize, capitalize the value
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
// Otherwise return the value unchanged
return value
},
})
</script>
<template>
<!-- Bind the model ref directly to the native input -->
<input v-model="model" />
</template>
<!-- ============================================
PART 2: THE PARENT COMPONENT
============================================ -->
<script setup>
import { ref } from 'vue'
import CapitalizedInput from './CapitalizedInput.vue'
// The bound value
const name = ref('')
</script>
<template>
<!-- Use the custom modifier on the component -->
<CapitalizedInput v-model.capitalize="name" />
<!-- Display the value to verify the transformation -->
<p>Value: {{ name }}</p>
</template>
<!-- ============================================
PART 3: THE MANUAL EQUIVALENT (pre-3.4)
============================================ -->
<!-- CapitalizedInputManual.vue -->
<script setup>
import { computed } from 'vue'
// Declare the modelValue prop and the modelModifiers prop
const props = defineProps({
modelValue: String,
modelModifiers: { type: Object, default: () => ({}) },
})
// Declare the update event
const emit = defineEmits(['update:modelValue'])
// A writable computed ref backed by the prop
const model = computed({
get: () => props.modelValue,
set: (value) => {
// Apply the modifier in the setter
if (props.modelModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
// Emit the transformed value
emit('update:modelValue', value)
},
})
</script>
<template>
<input v-model="model" />
</template>
The three parts cover the defineModel version, the parent that uses v-model.capitalize, and the manual computed version for older projects. What the session demonstrates is that the modifier is a flag delivered to the component, and the component applies the transformation in the setter. The defineModel version is shorter, but the manual version is the same contract with more boilerplate.
Quick Reference
Native vs Custom Expansion
| Context | Prop | Event |
|---|---|---|
| Native input | value | input |
| Native checkbox | checked | change |
| Custom component | modelValue | update:modelValue |
Built-in Modifiers
| Modifier | Effect | Applies to |
|---|---|---|
.trim | Strip whitespace | Text inputs |
.number | Cast to number | Text/number inputs |
.lazy | Sync on change | Text inputs |
Modifier Prop Names
| Binding | Modifier Prop |
|---|---|
v-model | modelModifiers |
v-model:title | titleModifiers |
v-model:firstName | firstNameModifiers |
defineModel Options
| Option | Effect |
|---|---|
required: true | Prop is required |
default: value | Default value (local to child) |
set(value) | Transform before emit |
get(value) | Transform before display |
Best Practices
✅ Do This:
<!-- Use defineModel for the default v-model -->
const model = defineModel()
<!-- Use defineModel with a name for multiple bindings -->
const title = defineModel('title')
<!-- Apply modifiers in the set option -->
const [model, modifiers] = defineModel({ set: (v) => modifiers.trim ? v.trim() : v })
<!-- Check the correct modifier prop name for named models -->
const [title, titleModifiers] = defineModel('title')
❌ Don’t Do This:
<!-- Declare value/input props in Vue 3 -->
props: ['value'] // v-model will not bind
<!-- Mutate the prop directly -->
props.modelValue = 'new' // runtime warning, parent not updated
<!-- Ignore modelModifiers on a custom component -->
// v-model.trim will be silently ineffective
<!-- Read modelModifiers for a named model -->
// v-model:title.trim uses titleModifiers, not modelModifiers
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
v-model not binding | Used value/input props | Use modelValue/update:modelValue |
| Modifier has no effect | Component ignores modelModifiers | Check the flag and apply the transformation |
| Named model modifier fails | Read modelModifiers instead of titleModifiers | Use the correct prop name |
| Prop mutation warning | Child assigns to modelValue | Emit update:modelValue |
| Default value desync | Parent has undefined, child has default | Parent should provide the initial value |
Real-World Examples
1. Native trim
<input v-model.trim="email" />
2. Native number
<input v-model.number="age" type="number" />
3. Custom component with defineModel
const model = defineModel()
<input v-model="model" />
4. Custom modifier with defineModel
const [model, modifiers] = defineModel({
set: (v) => modifiers.capitalize ? v[0].toUpperCase() + v.slice(1) : v,
})
5. Multiple named models
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
6. Manual modelModifiers prop
const props = defineProps({ modelValue: String, modelModifiers: Object })
7. Check modifier flag
if (props.modelModifiers.trim) value = value.trim()
8. Named model modifier prop
const [title, titleModifiers] = defineModel('title')
9. Writable computed (pre-3.4)
const model = computed({ get: () => props.modelValue, set: (v) => emit('update:modelValue', v) })
10. Chain modifiers
<input v-model.trim.lazy="username" />
Visual
Compile Expansion
┌──────────────────────────────────────────────┐
│ NATIVE: v-model="text" │
│ → :value="text" @input="text = $event.target.value" │
│ │
│ CUSTOM: v-model="text" │
│ → :modelValue="text" │
│ @update:modelValue="text = $event" │
└──────────────────────────────────────────────┘
Modifier Delivery
┌──────────────────────────────────────────────┐
│ PARENT: v-model.trim="name" │
│ │ │
│ ▼ │
│ CHILD receives: │
│ modelValue = current value │
│ modelModifiers = { trim: true } │
│ │ │
│ ▼ │
│ CHILD applies: │
│ if (modifiers.trim) value = value.trim() │
│ │ │
│ ▼ │
│ CHILD emits: update:modelValue │
└──────────────────────────────────────────────┘
Named Model Modifiers
┌──────────────────────────────────────────────┐
│ v-model:title.capitalize="name" │
│ │ │
│ ▼ │
│ CHILD receives: │
│ title prop = current value │
│ titleModifiers = { capitalize: true } │
│ (NOT modelModifiers) │
└──────────────────────────────────────────────┘
defineModel Contract
┌──────────────────────────────────────────────┐
│ const [model, modifiers] = defineModel() │
│ │ │
│ ▼ │
│ Compiler generates: │
│ - modelValue prop │
│ - update:modelValue emit │
│ - writable ref (get prop, set emit) │
│ - modifiers object from modelModifiers prop │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Native expansion | :value + @input |
| Custom expansion | :modelValue + @update:modelValue |
| Built-in modifiers | .trim, .number, .lazy |
| Custom modifier prop | modelModifiers (default) |
| Named modifier prop | <name>Modifiers |
defineModel return | [modelRef, modifiers] |
set option | Transform before emit |
| Vue 3.4+ | defineModel stable |
| Pre-3.4 | Manual computed with get/set |
Key takeaways:
v-modelis a compile-time transformation. On native inputs, it expands to a value binding and an input listener. On custom components, it expands to amodelValueprop and anupdate:modelValueevent listener .- Vue 3 changed the default prop and event names. The old
value/inputpair is replaced bymodelValue/update:modelValue. Components that still use the old names will silently fail to bind . - Built-in modifiers are automatic on native inputs but manual on custom components. The component receives
modelModifiersas a prop and must check the flags and apply the transformation itself . - Named models use their own modifier prop.
v-model:title.capitalizedeliverstitleModifiers, notmodelModifiers. Reading the wrong prop is a silent failure . defineModelsimplifies the contract. It returns a writable ref and a modifiers object, and thesetoption is the transformation hook. It requires Vue 3.4+ and<script setup>.- The child never mutates the prop directly. The correct path is always to emit
update:modelValue. ThedefineModelref does this internally, but the manual version must do it explicitly . - Multiple
v-modelbindings replaced.sync. Vue 3 supportsv-model:firstNameandv-model:lastNameon the same component, each with its own prop and event .
Remember: v-model is not a single directive with a single behavior. It is a contract between the parent and the child, and the compiler generates the plumbing. On native inputs, the plumbing is value and input. On custom components, the plumbing is modelValue and update:modelValue. The modifiers are flags that travel with the binding, and the component decides how to honor them. defineModel reduces the boilerplate, but the underlying contract is the same: the parent passes a prop, the child emits an event, and the value flows in a single direction with an explicit update signal. Understanding this contract is what turns v-model from magic into a predictable mechanism.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!