Vue.js 14 🟢 v-model Modifiers (.lazy, .number, .trim)
The previous chapter introduced v-model as two-way binding and mentioned three modifiers—.lazy, .number, and .trim—as adjustments to the default behavior. This chapter covers them in full. They are small. Each one changes a single aspect of how the binding works. But those aspects matter: the timing of updates, the type of the bound value, and the content of the value after the user has entered it. A form that validates on every keystroke and a form that validates on blur are different forms. A field that binds '42' and one that binds 42 behave differently when compared with ===. A username with a trailing space and one without are not the same username.
This chapter covers each modifier individually, then in combination. You will learn what each one does at the event level, when to reach for it, and how it interacts with the others. You will also see the cases where the modifier is not enough—where a computed property or a custom component with defineModel() provides the control the modifier cannot.
Key point: v-model modifiers are applied at the point where the value is read from or written to the input. .lazy changes which event triggers the update. .number changes how the value is parsed. .trim changes the value before it is stored. They can be chained, and they apply in a fixed order regardless of how they are written.
Why v-model modifiers exist
The timing problem. By default, v-model on a text input updates the bound value on every input event—that is, on every keystroke. For a search field that triggers an API call, this means a request per character. For a form that validates a password’s strength, it means re-evaluating on every character. Sometimes this is desirable; the UI should reflect the current value immediately. Sometimes it is wasteful or noisy. The .lazy modifier changes the trigger to the change event, which fires when the field loses focus or when the user commits the value with Enter.
The type problem. HTML inputs produce strings. event.target.value is always a string, regardless of the input’s type attribute. A <input type="number"> still reports its value as a string. For a numeric field—quantity, age, price, rating—the application usually wants a number. The .number modifier parses the value and stores a number when the input can be interpreted as one. Without it, every comparison and arithmetic operation requires an explicit conversion.
The whitespace problem. User input often contains leading or trailing whitespace. A copied-and-pasted email might have a trailing space. A typed username might start with a space if the user’s finger slipped. In most cases, the whitespace is not meaningful and should be removed before the value is stored. The .trim modifier strips it. Without it, validation might fail on a value that looks correct to the user but contains an invisible space.
The composition problem. Modifiers work together. A numeric field that updates on change and trims whitespace is a common combination. v-model.trim.lazy.number expresses all three. The framework applies them in a consistent order, so the result is predictable.
The boundary problem. Modifiers cover the common cases. They do not cover every transformation. A field that should be uppercased, or formatted as a phone number, or parsed into a Date needs more than a modifier. For those cases, a computed property with a custom setter or a custom component with defineModel() provides the control that modifiers cannot. Knowing where the modifier’s responsibility ends and the application’s begins is part of using them well.
a. The .lazy modifier
By default, v-model listens for the input event. Every keystroke updates the bound value. The .lazy modifier changes the listener to the change event.
<input v-model.lazy="name">
The change event fires when the element loses focus and its value has changed since the last change event. It also fires when the user presses Enter in a single-line text input. The bound value is not updated while the user is typing; it is updated when the user finishes.
The practical difference:
<!-- Updates on every keystroke -->
<input v-model="query">
<!-- Bound value: 'h', 'he', 'hel', 'hell', 'hello' -->
<!-- Updates on change -->
<input v-model.lazy="query">
<!-- Bound value: 'hello' (after blur or Enter) -->
The .lazy modifier is appropriate when the binding triggers expensive work: a search request, a validation call, a recalculation. It is not appropriate when the UI should reflect the current value immediately—a live character counter, a preview pane, a filtered list. For those, the default input event is correct.
A common pattern is to use .lazy for a search field and trigger the search in a watcher or on a change event. The user types freely, and the search runs when they pause. Without .lazy, a debounce mechanism is needed to achieve the same result.
b. The .number modifier
The .number modifier parses the input value as a number. If the input cannot be parsed, the raw string is used.
<input v-model.number="age" type="number">
With .number, age is a number when the input contains a valid numeric value. Without it, age is the string '25'. The difference matters for comparisons and arithmetic:
// Without .number
age.value === 25 // false ('25' !== 25)
age.value + 1 // '251' (string concatenation)
// With .number
age.value === 25 // true
age.value + 1 // 26
The .number modifier uses parseFloat internally. A value like '25abc' is not parseable and is stored as the string '25abc'. A value like '25.5' is stored as the number 25.5. An empty input is stored as an empty string, not as 0 or NaN.
The modifier works on text inputs as well as number inputs. Using it on a text input tells Vue to attempt numeric parsing regardless of the input type.
<input v-model.number="quantity">
This is useful when the type="number" attribute is not present—for example, a field that accepts either a number or a range string—and the application still wants numeric coercion when possible.
A common pitfall is expecting .number to validate. It does not. It parses. A negative number, a fractional number, a number larger than the application expects—all are stored as numbers. Validation is the application’s responsibility.
c. The .trim modifier
The .trim modifier removes leading and trailing whitespace from the input before storing it.
<input v-model.trim="username">
If the user types ' alice ', the bound value is 'alice'. Internal whitespace is preserved: 'alice smith' becomes 'alice smith', not 'alicesmith'. Only leading and trailing whitespace is removed.
The modifier is applied when the value is read from the input. It does not modify what the user sees in the field. If the user types trailing spaces, they remain visible until the field is updated programmatically. The bound value is trimmed, but the displayed value is not.
This distinction is occasionally confusing. In the following example, the input displays ' alice ' while the bound value is 'alice':
<input v-model.trim="username">
<p>Stored: '{{ username }}'</p>
If the user types ' alice ' and then looks at the paragraph, it shows 'Stored: alice'. The field still shows the spaces. On the next render, Vue may update the field to the trimmed value, but the timing depends on the binding.
The .trim modifier is most useful for text fields where whitespace is not meaningful: usernames, emails, search queries, tag inputs. It is not appropriate for fields where whitespace is part of the value—a code editor, a textarea for a poem, a field that accepts formatted text.
Complete Example Session
<!-- ============================================ -->
<!-- PART 1: DEFAULT v-model (INPUT EVENT) -->
<!-- ============================================ -->
<template>
<input v-model="query">
<p>Value: '{{ query }}'</p>
</template>
<script setup>
import { ref } from 'vue'
const query = ref('')
</script>
<!-- ============================================ -->
<!-- PART 2: .lazy MODIFIER (CHANGE EVENT) -->
<!-- ============================================ -->
<template>
<input v-model.lazy="query">
<p>Value: '{{ query }}'</p>
</template>
<script setup>
import { ref } from 'vue'
const query = ref('')
</script>
<!-- ============================================ -->
<!-- PART 3: .number MODIFIER -->
<!-- ============================================ -->
<template>
<input v-model.number="age" type="number">
<p>Age: {{ age }} ({{ typeof age }})</p>
</template>
<script setup>
import { ref } from 'vue'
const age = ref(0)
</script>
<!-- ============================================ -->
<!-- PART 4: .number ON TEXT INPUT -->
<!-- ============================================ -->
<template>
<input v-model.number="quantity">
<p>Quantity: {{ quantity }} ({{ typeof quantity }})</p>
</template>
<script setup>
import { ref } from 'vue'
const quantity = ref(0)
</script>
<!-- ============================================ -->
<!-- PART 5: .trim MODIFIER -->
<!-- ============================================ -->
<template>
<input v-model.trim="username">
<p>Stored: '{{ username }}'</p>
</template>
<script setup>
import { ref } from 'vue'
const username = ref('')
</script>
<!-- ============================================ -->
<!-- PART 6: CHAINED MODIFIERS -->
<!-- ============================================ -->
<template>
<input v-model.trim.lazy="email">
<p>Email: '{{ email }}'</p>
</template>
<script setup>
import { ref } from 'vue'
const email = ref('')
</script>
<!-- ============================================ -->
<!-- PART 7: ALL THREE MODIFIERS -->
<!-- ============================================ -->
<template>
<input v-model.trim.lazy.number="price">
<p>Price: {{ price }} ({{ typeof price }})</p>
</template>
<script setup>
import { ref } from 'vue'
const price = ref(0)
</script>
<!-- ============================================ -->
<!-- PART 8: .lazy FOR EXPENSIVE OPERATIONS -->
<!-- ============================================ -->
<template>
<input v-model.lazy="searchQuery">
<p>Results for: {{ searchQuery }}</p>
</template>
<script setup>
import { ref, watch } from 'vue'
const searchQuery = ref('')
watch(searchQuery, (value) => {
if (value) fetchResults(value)
})
function fetchResults(q) {
console.log('Fetching:', q)
// API call runs only on change, not on every keystroke
}
</script>
<!-- ============================================ -->
<!-- PART 9: .number WITH VALIDATION -->
<!-- ============================================ -->
<template>
<input v-model.number="age" type="number">
<p v-if="age < 0">Age cannot be negative</p>
<p v-else-if="age > 150">Age seems too high</p>
<p v-else>Valid age: {{ age }}</p>
</template>
<script setup>
import { ref } from 'vue'
const age = ref(0)
</script>
<!-- ============================================ -->
<!-- PART 10: BEYOND MODIFIERS — COMPUTED SETTER -->
<!-- ============================================ -->
<template>
<input v-model="formattedPhone">
<p>Raw: {{ rawPhone }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const rawPhone = ref('')
const formattedPhone = computed({
get: () => rawPhone.value,
set: (value) => {
rawPhone.value = value.replace(/\D/g, '')
}
})
</script>
The ten parts covered the default input event, .lazy with change, .number on a number input, .number on a text input, .trim, chained .trim.lazy, all three modifiers, .lazy for expensive operations, .number with validation, and a computed setter for transformations beyond what modifiers support.
Quick Reference
Modifier Summary
| Modifier | Event | Effect |
|---|---|---|
| (none) | input | Updates on every keystroke |
.lazy | change | Updates on blur or Enter |
.number | — | Parses value as number |
.trim | — | Removes leading/trailing whitespace |
When to Use Each
| Modifier | Use When |
|---|---|
.lazy | Binding triggers expensive work |
.number | Value should be numeric |
.trim | Whitespace is not meaningful |
| Chained | Multiple conditions apply |
Chaining Order
| Written | Applied |
|---|---|
v-model.trim.lazy | Trim on change |
v-model.lazy.trim | Same as above |
v-model.trim.lazy.number | Number, trim, on change |
Modifiers are applied in a fixed internal order, not the order written. The result is identical.
Modifier Limits
| Transformation | Modifier | Alternative |
|---|---|---|
| Update on change | .lazy | Watcher with debounce |
| Parse to number | .number | Computed setter |
| Trim whitespace | .trim | Computed setter |
| Uppercase | None | Computed setter |
| Format phone | None | Computed setter |
| Parse to Date | None | Computed setter |
Best Practices
✅ Do This:
<!-- Use .lazy for search fields -->
<input v-model.lazy="searchQuery"> <!-- ✅ -->
<!-- Use .number for numeric fields -->
<input v-model.number="quantity" type="number"> <!-- ✅ -->
<!-- Use .trim for user input -->
<input v-model.trim="email"> <!-- ✅ -->
<!-- Chain when multiple adjustments are needed -->
<input v-model.trim.lazy.number="price"> <!-- ✅ -->
<!-- Validate after .number, not instead of it -->
<input v-model.number="age" type="number"> <!-- ✅ -->
<p v-if="age < 0">Invalid</p> <!-- ✅ -->
<!-- Use computed setter for transformations beyond modifiers -->
const formatted = computed({
get: () => raw.value,
set: (v) => raw.value = transform(v)
}) <!-- ✅ -->
❌ Don’t Do This:
<!-- Don't use .lazy when live updates are needed -->
<input v-model.lazy="query">
<p>{{ query.length }} characters</p> <!-- ❌ stale -->
<!-- Don't expect .number to validate -->
<input v-model.number="age">
<!-- -5 is a valid number --> <!-- ⚠️ -->
<!-- Don't use .trim when whitespace is meaningful -->
<textarea v-model.trim="poem"></textarea> <!-- ❌ -->
<!-- Don't chain .number with text inputs expecting strict parsing -->
<input v-model.number="name"> <!-- ⚠️ -->
<!-- Don't forget that .number stores empty string for empty input -->
<input v-model.number="count">
<!-- count is '' when input is empty, not 0 --> <!-- ⚠️ -->
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Value not updating while typing | .lazy used | Remove .lazy |
| String instead of number | No .number | Add .number |
| Whitespace in value | No .trim | Add .trim |
.number returns string | Input not parseable | Validate or use computed setter |
.trim removes intentional spaces | Wrong field type | Remove .trim |
| Chained modifiers confusing | Order looks significant | Order does not matter |
Empty input gives '' | .number with empty value | Check for empty string |
Real-World Examples
1. Search Field with Lazy Update
<input v-model.lazy="query" placeholder="Search">
2. Quantity Input
<input v-model.number="quantity" type="number" min="1">
3. Email Input
<input v-model.trim="email" type="email">
4. Price Input
<input v-model.trim.lazy.number="price" type="number" step="0.01">
5. Username Field
<input v-model.trim="username" placeholder="Username">
6. Age with Validation
<input v-model.number="age" type="number">
<p v-if="age < 0 || age > 150">Invalid age</p>
7. Tag Input
<input v-model.trim="newTag" @keyup.enter="addTag">
8. Comment Field (No Trim)
<textarea v-model="comment" rows="4"></textarea>
9. Formatted Phone
<script setup>
const raw = ref('')
const phone = computed({
get: () => raw.value,
set: (v) => raw.value = v.replace(/\D/g, '')
})
</script>
<template>
<input v-model="phone">
</template>
10. Uppercase Code
<script setup>
const raw = ref('')
const code = computed({
get: () => raw.value,
set: (v) => raw.value = v.toUpperCase()
})
</script>
<template>
<input v-model="code">
</template>
Visual
Event Timing
┌─────────────────────────────────────────────────────────────┐
│ v-model (DEFAULT): input EVENT │
│ │
│ User types: h → e → l → l → o │
│ │
│ Bound value updates: │
│ 'h' → 'he' → 'hel' → 'hell' → 'hello' │
│ │
│ Five updates, one per keystroke. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ v-model.lazy: change EVENT │
│ │
│ User types: h → e → l → l → o → blur │
│ │
│ Bound value updates: │
│ 'hello' (once, on blur) │
│ │
│ One update, on commit. │
│ │
└─────────────────────────────────────────────────────────────┘
Type Coercion
┌─────────────────────────────────────────────────────────────┐
│ WITHOUT .number │
│ │
│ <input v-model="age" type="number"> │
│ │
│ User types "25" │
│ age = '25' (string) │
│ │
│ age + 1 → '251' (concatenation) │
│ age === 25 → false │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WITH .number │
│ │
│ <input v-model.number="age" type="number"> │
│ │
│ User types "25" │
│ age = 25 (number) │
│ │
│ age + 1 → 26 │
│ age === 25 → true │
│ │
└─────────────────────────────────────────────────────────────┘
Trim Behavior
┌─────────────────────────────────────────────────────────────┐
│ v-model.trim="username" │
│ │
│ User types: ' alice ' │
│ │
│ Input displays: ' alice ' │
│ Bound value: 'alice' │
│ │
│ Leading and trailing spaces removed. │
│ Internal spaces preserved. │
│ │
│ 'alice smith' → 'alice smith' (unchanged) │
│ ' alice smith ' → 'alice smith' │
│ │
└─────────────────────────────────────────────────────────────┘
Modifier Chaining
┌─────────────────────────────────────────────────────────────┐
│ v-model.trim.lazy.number="price" │
│ │
│ When user types and blurs: │
│ │
│ 1. change event fires │
│ 2. Raw value: ' 25.50 ' │
│ 3. .trim removes whitespace: '25.50' │
│ 4. .number parses: 25.5 │
│ 5. Bound value: 25.5 (number) │
│ │
│ Order of modifiers in the attribute does not matter. │
│ The framework applies them consistently. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Default event | input |
.lazy event | change |
.number parsing | parseFloat |
.number on empty | Empty string |
.number on invalid | Raw string |
.trim removes | Leading and trailing whitespace |
.trim preserves | Internal whitespace |
| Chaining | All modifiers combine |
| Order in attribute | Does not affect result |
| Beyond modifiers | Computed setter |
Key takeaways:
.lazychanges the trigger frominputtochange. The bound value updates on blur or Enter rather than on every keystroke. Use it when the binding triggers expensive work or when live updates are not needed..numberparses the value as a number. It usesparseFloat, storing a number when the input is parseable and the raw string when it is not. An empty input stores an empty string, not0..numberdoes not validate. It parses. A negative number, an out-of-range number, or an unexpected format is still stored as a number. Validation is the application’s responsibility..trimremoves leading and trailing whitespace. It preserves internal whitespace. Use it for usernames, emails, and search queries. Do not use it for fields where whitespace is part of the value.- Modifiers chain.
v-model.trim.lazy.numberapplies all three. The order in the attribute does not affect the result; the framework applies them consistently. - Modifiers cover the common cases. For transformations beyond them—uppercase, phone formatting, date parsing—a computed property with a custom setter or a custom component with
defineModel()provides the control. - The
.lazymodifier is not the same as debouncing. It changes when updates occur, but it does not add a delay. For true debouncing, a watcher with a timer is needed.
Remember: v-model modifiers are small adjustments with specific purposes. .lazy changes when the binding updates. .number changes what type the value has. .trim changes what the value contains. They are not alternatives to each other; they address different concerns, and they compose. Use them when the default behavior is not what you want, and use a computed setter when the transformation is more than a modifier can express. The modifiers are the first tool to reach for, and the computed setter is the one to reach for when the modifier is not enough.
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!