v-model Modifiers
How .lazy, .number and .trim change when a v-model binding writes to state and what value it writes.
<input v-model.lazy.trim="query">v-model has three built-in modifiers. Each one changes the write half of the binding: either when it fires, or what value it stores.
| Modifier | Effect |
|---|---|
.lazy | Listens for change instead of input |
.number | Casts a valid input string to a number |
.trim | Trims whitespace from the input |
.lazy
By default a text input syncs on every keystroke, because it listens for input. .lazy swaps that for change, which for a text field fires on blur or when Enter is pressed. So v-model.lazy="query" is the same pair of bindings as :value="query" plus @change. Anything watching the state, including a computed, then runs once per edit rather than once per character.
.number
Form values arrive as strings. .number runs the value through parseFloat before storing it. The guide is explicit about the fallback: if the value cannot be parsed with parseFloat(), the original value is used instead. It is applied automatically when the element is type="number", so you rarely need to write it there.
.trim
.trim removes leading and trailing whitespace from what is stored. It does not collapse spaces inside the value, and it does not stop a field containing only spaces from being stored as an empty string.
Modifiers chain: v-model.lazy.trim="email" is valid and common.
Example
<!-- OrderFields.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
// .number can store a string, so the type says so
const quantity = ref<number | string>(1)
const email = ref('')
const search = ref('')
const unitPrice = 12
const total = computed(() =>
typeof quantity.value === 'number' ? quantity.value * unitPrice : null
)
</script>
<template>
<label for="email">Email</label>
<!-- .trim: the ref never holds surrounding spaces -->
<input id="email" v-model.trim="email" type="email" />
<label for="quantity">Quantity</label>
<!-- .number: parsed with parseFloat before the ref is written -->
<input id="quantity" v-model.number="quantity" type="number" min="1" />
<label for="search">Search</label>
<!-- .lazy: writes on change, so on blur or Enter, not per keystroke -->
<input id="search" v-model.lazy="search" />
<p>Email: {{ email }}</p>
<p>Total: {{ total === null ? 'enter a number' : total }}</p>
<p>Searching for: {{ search || 'nothing yet' }}</p>
</template>
<style scoped>
label {
display: block;
}
</style>When to use it
- Keep a pasted email from carrying a trailing space into your API payload.
- Store a quantity as a number so a total can multiply it directly.
- Hold off updating an expensive filter until the field loses focus.
- Show a validation message once per edit rather than on every keystroke.
More examples
What .number does to each value
Plain functions, so the results can be printed. parseFloat stops at the first character it cannot read, which is why '42px' becomes 42, and the unparseable cases fall back to the original string. This is the reason a ref behind v-model.number is typed number | string, and the reason a form still needs its own validation before it posts.
// src/lib/to-number.ts
// equivalent to the cast v-model.number performs
export function toNumber(raw: string): number | string {
const parsed = parseFloat(raw)
return Number.isNaN(parsed) ? raw : parsed
}
console.log(toNumber('42'))
// Output: 42
console.log(toNumber('42px'))
// Output: 42
console.log(toNumber('abc'))
// Output: 'abc'
console.log(toNumber(''))
// Output: ''.lazy and the longhand it desugars to
Type in the first field and the paragraph tracks every character. Type in either of the others and nothing changes until the field loses focus or you press Enter. Reach for .lazy when each state change is expensive and a per-keystroke update buys nothing, for example a filter over a long list.
<!-- FilterRow.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const eager = ref('')
const lazy = ref('')
</script>
<template>
<!-- default: writes on every keystroke -->
<input v-model="eager" />
<!-- .lazy: writes on change -->
<input v-model.lazy="lazy" />
<!-- the same bindings as the line above, written out -->
<input
:value="lazy"
@change="(event) => (lazy = (event.target as HTMLInputElement).value)"
/>
<p>Eager: {{ eager }}</p>
<p>Lazy: {{ lazy }}</p>
</template>Chaining modifiers on a real field
Chained modifiers apply together: the value is trimmed and it is written on change, so the validity message appears once the field is left rather than flickering as a partial code is typed. Pasted codes with a stray trailing space pass, which is the main thing .trim buys on a field like this.
<!-- CouponField.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
const code = ref('')
// the ref is already trimmed, so the check is about content only
const looksValid = computed(() => /^[A-Z0-9]{6}$/.test(code.value))
</script>
<template>
<label for="code">Coupon code</label>
<input id="code" v-model.lazy.trim="code" autocomplete="off" />
<p v-if="code === ''">Enter a six-character code.</p>
<p v-else-if="looksValid">Code looks right.</p>
<p v-else>That is not a six-character code.</p>
</template>
<style scoped>
label {
display: block;
}
</style>
Discussion