Vue.js course Β· Module 5: Events & Forms
v-model Modifiers - tuning the signal from the panels
In this lesson5
In the previous lesson you wired the mission panel to variables: the crew name, the notes, the system checkboxes, the module list. The form works, but the signal leaving it is raw. The Mission Control duty officer reports three faults. First, the telemetry log records a new value after every keystroke, so a single word leaves a dozen entries behind it. Second, the credits field hands over text instead of a number, so every addition glues digits together instead of summing them. Third, the operator pastes a module name from the clipboard together with a trailing space, and the lookup against the station catalog stops matching.
You do not have to repair any of this by hand. Exactly as you appended .prevent or .stop to events, you can append a modifier to v-model: a dot and a name right after the directive name. A modifier does not change what the field is bound to - it changes the way the value travels into the variable. Vue offers three of them: .lazy, .number and .trim. Each one repairs exactly one of the duty officer's faults.
When the value lands in the variable
Let us start with the first fault, because it is about timing rather than content. A text field with v-model listens for the browser event called input, which the browser fires after every single character. For a field with a live preview that is ideal behavior - the operator types, the panel shows the effect at once. For a telemetry log, or for an expensive trajectory recalculation, it is waste: we care about one finished value, not about a dozen intermediate states.
The browser has a separate event for exactly this occasion: change. On a text field it fires only once the altered value has been committed - that is, when you leave the field (the blur event) or press Enter. The .lazy modifier does one single thing: it switches the v-model listener from input over to change. Below are two identically built readout channels that differ by nothing except that modifier.
1<template>
2 <label>Live (input)</label>
3 <input v-model="liveSignal" />
4 <p>Readout: {{ liveSignal }}</p>
5
6 <label>Lazy (change)</label>
7 <input v-model.lazy="committedSignal" />
8 <p>Readout: {{ committedSignal }}</p>
9</template>
10
11<script setup>
12import { ref } from 'vue'
13
14const liveSignal = ref('')
15const committedSignal = ref('')
16</script>Type something into the first field - the paragraph beneath it changes letter by letter. Type the same thing into the second one - the paragraph stays empty until you click away or press Enter. And now the most valuable observation: what did not change. The binding is still two-way, committedSignal is still ordinary text, and the template differs by five characters. Only the moment of the update moved.
Three misunderstandings are worth ruling out immediately, because .lazy gets confused with things it is not. It is not a countdown - nothing happens every 500 milliseconds. A delay measured by a clock is called debounce, and in Vue you build it yourself, with a watcher and setTimeout, just as in the module on station monitoring systems. It is also not waiting for the form to be submitted - the value lands in the variable the moment you leave the field, long before anybody clicks a button. And finally: the update is still automatic, you never rewrite anything manually.
The number that only pretends to be text
The second fault is sneakier, because the panel looks correct right up until the first piece of arithmetic. A form field in the browser always hands back text - even when you type nothing but digits into it. So if you assign that value to a variable and add a number to it, JavaScript will not compute a sum, it will glue two pieces of text together. See it on the mission credits field, for now with no modifier at all.
1<template>
2 <label>Credits</label>
3 <input v-model="budget" />
4
5 <p>Type: {{ typeof budget }}</p>
6 <p>budget + 10 = {{ budget + 10 }}</p>
7</template>
8
9<script setup>
10import { ref } from 'vue'
11
12const budget = ref('')
13</script>Type 1500. The first paragraph shows string, and the second one - instead of 1510 - a piece of text made of the value you typed with a ten stuck onto the end. This is not a Vue bug and not a browser bug, only the natural consequence of a form field storing characters. As long as the value is used purely for display, nobody notices. The trouble begins when you sum budgets, compare limits and sort modules by cost.
The cure is the .number modifier. Vue then runs the typed value through a numeric conversion - it behaves like parseFloat: a number is pulled out of the text, and if the conversion fails, the untouched original goes into the variable. This is a very important distinction: .number converts, but it does not validate. It will not reject an entry, light a red lamp or lock the form - if the content cannot be turned into a number, it simply leaves the text exactly as it was. Checking correctness is separate work, and you will do it in the next lesson.
1<template>
2 <label>Credits</label>
3 <input v-model.number="budget" type="number" />
4
5 <p>Type: {{ typeof budget }}</p>
6 <p>budget + 10 = {{ budget + 10 }}</p>
7</template>
8
9<script setup>
10import { ref } from 'vue'
11
12const budget = ref(0)
13</script>The same template, one modifier added - and the first paragraph now shows number, while the second shows a genuine sum. Notice what .number does not do. It does not make sure that only digits can be entered - that is the job of the type="number" attribute, which belongs to the browser and decides what may be tapped into the field in the first place. Nor does it format the number with thousands separators; that reader-friendly notation is produced separately, at display time, with toLocaleString. Inside the field itself the value stays a raw number.
Since the type="number" attribute has already appeared, one honest caveat. Vue treats such a field as numeric by assumption and attempts the conversion even without the modifier, so in this particular example .number changes nothing. I still recommend writing it always and explicitly. First, the intent is then visible in the template and does not depend on which type somebody swaps in later. Second, the very same notation also works on a plain text field. The canonical form worth having in your fingers looks exactly like the block above: first <input, then v-model.number= with the variable name in quotes, and type="number" at the end.
Spaces that nobody typed
The third fault comes from a human reflex. The operator copies a module name out of a report and carries a trailing space along with it. Nothing alarming shows up on screen, but the variable now holds a character that ruins everything which compares text: a search through the station catalog fails to find the module, and a naive "is the field empty" check lets through an entry made of nothing but spaces. The .trim modifier removes whitespace from the beginning and the end of the value before it reaches the variable.
1<template>
2 <label>Raw</label>
3 <input v-model="rawName" />
4 <p>chars: {{ rawName.length }}</p>
5
6 <label>Trimmed</label>
7 <input v-model.trim="missionName" />
8 <p>chars: {{ missionName.length }}</p>
9</template>
10
11<script setup>
12import { ref } from 'vue'
13
14const rawName = ref('')
15const missionName = ref('')
16</script>Type a name with a space at the front and at the back into both fields. The character counter under the first field counts those spaces, the counter under the second one does not. Now three boundaries that are easy to forget. .trim does not shorten text to a given length - there is no character limit whatsoever, only the edges get cut. Neither does it remove every space: the name "Oxygen Alpha" keeps the space in its middle, because the modifier never touches the inside of the string. And it does not change letter case - lowercasing is toLowerCase, an entirely different tool. Just as importantly, the modifier works on the value travelling toward the variable, not on what you physically tap into the field - while you type, the space is visible in the field, the variable simply never receives it.
Chaining modifiers
The mission registration panel needs all three repairs at once, and you do not have to choose just one. Modifiers are chained: you write the names one after another, each preceded by a dot, with no spaces and no commas. The form v-model.lazy.trim="text" is perfectly valid - whereas a comma placed between the names would be read by Vue as part of one single unknown modifier name, and would simply do nothing.
Vue does not impose an order, so v-model.trim.lazy means precisely the same as v-model.lazy.trim. Since the choice belongs to you, I recommend keeping one steady convention: first the modifier describing what the value is (.number or .trim), then the one describing when it reaches the variable (.lazy). Hence v-model.number.lazy - it reads like a sentence: a number, committed once you leave the field.
1<template>
2 <form @submit.prevent="registerMission">
3 <label>Mission name</label>
4 <input v-model.trim.lazy="missionName" />
5
6 <label>Credits</label>
7 <input v-model.number.lazy="budget" type="number" />
8
9 <button type="submit">Register</button>
10 </form>
11
12 <p>Panel: {{ missionName }} / {{ budget }}</p>
13</template>
14
15<script setup>
16import { ref } from 'vue'
17
18const missionName = ref('')
19const budget = ref(0)
20
21function registerMission() {
22 console.log('Mission registered', missionName.value, budget.value)
23}
24</script>Fill in both fields and move between them with the Tab key, without touching the button at all. The paragraph under the form already shows the trimmed name and the numeric budget - proof that .lazy waits for you to leave the field, not for the form to be sent. Clicking the button takes focus away from the field first, so registerMission always receives current values. Apart from that there is nothing new in this block: @submit.prevent you know from the lesson on event modifiers, and ref together with v-model have been with you since the start of the module. All that has been added are the dots carrying modifiers.
When a modifier gets in the way
One warning at the end, because modifiers tend to be added out of reflex. Do not give .lazy to a field that filters the module list live - the operator loses the preview of results while typing and will declare the panel broken. Be careful with .number on designations that merely look like numbers: a sector number written as 007 turns into a plain seven after conversion and stops matching the catalog. With .trim on names, addresses and identifiers there is practically no trouble at all, and you may safely apply it by default.
Remember: a v-model modifier is the calibration dial beside the panel - the signal runs down the very same cable, but it reaches the mission computer already clean, in the right type and at the right moment.
Code for this lesson: App.vue
1<script setup>
2import { ref } from 'vue'
3
4const crewMember = ref({
5 name: '',
6 role: 'pilot',
7 certifications: [],
8 experience: 'beginner',
9 availability: false
10})
11
12const roles = [
13 { value: 'pilot', label: 'Pilot' },
14 { value: 'engineer', label: 'Engineer' },
15 { value: 'scientist', label: 'Scientist' },
16 { value: 'medic', label: 'Medic' },
17 { value: 'commander', label: 'Commander' }
18]
19
20const availableCertifications = [
21 { id: 'eva', name: 'EVA (Spacewalk)' },
22 { id: 'repair', name: 'Ship Repair' },
23 { id: 'bio', name: 'Biological Research' },
24 { id: 'geo', name: 'Martian Geology' },
25 { id: 'comm', name: 'Communication Systems' }
26]
27
28const experienceLevels = [
29 { value: 'beginner', label: 'Beginner (0-2 years)' },
30 { value: 'intermediate', label: 'Intermediate (3-5 years)' },
31 { value: 'advanced', label: 'Advanced (6-10 years)' },
32 { value: 'expert', label: 'Expert (10+ years)' }
33]
34</script>
35
36<template>
37 <div class="crew-registration">
38 <div class="registration-header">
39 <h2>Crew Member Registration</h2>
40 <p class="subtitle">Forms - Different Input Types</p>
41 </div>
42
43 <div class="form-section">
44 <!-- Text Input -->
45 <div class="form-field">
46 <label for="name">Full name:</label>
47 <input
48 id="name"
49 v-model="crewMember.name"
50 type="text"
51 placeholder="e.g. John Smith"
52 class="text-input"
53 />
54 </div>
55
56 <!-- Radio Buttons -->
57 <div class="form-field">
58 <label>Role in the mission:</label>
59 <div class="radio-group">
60 <label
61 v-for="role in roles"
62 :key="role.value"
63 class="radio-label"
64 >
65 <input
66 v-model="crewMember.role"
67 type="radio"
68 :value="role.value"
69 class="radio-input"
70 />
71 <span class="radio-text">{{ role.label }}</span>
72 </label>
73 </div>
74 </div>
75
76 <!-- Checkboxes -->
77 <div class="form-field">
78 <label>Certificates (select all you have):</label>
79 <div class="checkbox-group">
80 <label
81 v-for="cert in availableCertifications"
82 :key="cert.id"
83 class="checkbox-label"
84 >
85 <input
86 v-model="crewMember.certifications"
87 type="checkbox"
88 :value="cert.id"
89 class="checkbox-input"
90 />
91 <span class="checkbox-text">{{ cert.name }}</span>
92 </label>
93 </div>
94 </div>
95
96 <!-- Select -->
97 <div class="form-field">
98 <label for="experience">Experience level:</label>
99 <select
100 id="experience"
101 v-model="crewMember.experience"
102 class="select-input"
103 >
104 <option
105 v-for="level in experienceLevels"
106 :key="level.value"
107 :value="level.value"
108 >
109 {{ level.label }}
110 </option>
111 </select>
112 </div>
113
114 <!-- Single Checkbox -->
115 <div class="form-field">
116 <label class="checkbox-label single">
117 <input
118 v-model="crewMember.availability"
119 type="checkbox"
120 class="checkbox-input"
121 />
122 <span class="checkbox-text">
123 Ready for immediate departure
124 </span>
125 </label>
126 </div>
127 </div>
128
129 <div class="data-display">
130 <h3>Crew Member Data</h3>
131 <div class="data-card">
132 <div class="data-row">
133 <span class="data-label">Name:</span>
134 <span class="data-value">{{ crewMember.name || '(not provided)' }}</span>
135 </div>
136 <div class="data-row">
137 <span class="data-label">Role:</span>
138 <span class="data-value">{{ roles.find(r => r.value === crewMember.role)?.label }}</span>
139 </div>
140 <div class="data-row">
141 <span class="data-label">Certifications:</span>
142 <span class="data-value">
143 {{ crewMember.certifications.length > 0
144 ? crewMember.certifications.join(', ').toUpperCase()
145 : '(none)' }}
146 </span>
147 </div>
148 <div class="data-row">
149 <span class="data-label">Experience:</span>
150 <span class="data-value">
151 {{ experienceLevels.find(l => l.value === crewMember.experience)?.label }}
152 </span>
153 </div>
154 <div class="data-row">
155 <span class="data-label">Availability:</span>
156 <span class="data-value">
157 {{ crewMember.availability ? 'Ready' : 'Unavailable' }}
158 </span>
159 </div>
160 </div>
161 </div>
162
163 <div class="binding-info">
164 <h3>Data binding for different inputs:</h3>
165 <ul>
166 <li><code><input type="text"></code> - String</li>
167 <li><code><input type="radio"></code> - Value of the selected radio</li>
168 <li><code><input type="checkbox"></code> - Boolean (single) or Array (multiple)</li>
169 <li><code><select></code> - Value of the selected option</li>
170 <li><code><textarea></code> - String</li>
171 </ul>
172 </div>
173 </div>
174</template>
175
176<style scoped>
177.crew-registration {
178 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
179 color: #00ff88;
180 padding: 30px;
181 border-radius: 15px;
182 border: 2px solid #00b4d8;
183 font-family: 'Courier New', monospace;
184 max-width: 900px;
185 margin: 0 auto;
186}
187
188.registration-header {
189 text-align: center;
190 margin-bottom: 30px;
191 padding-bottom: 20px;
192 border-bottom: 2px solid #00b4d8;
193}
194
195.registration-header h2 {
196 color: #00ff88;
197 margin: 0 0 10px 0;
198 font-size: 28px;
199 text-shadow: 0 0 10px #00ff88;
200}
201
202.subtitle {
203 color: #00b4d8;
204 margin: 0;
205 font-size: 14px;
206}
207
208.form-section {
209 margin-bottom: 30px;
210}
211
212.form-field {
213 margin-bottom: 25px;
214}
215
216.form-field > label {
217 display: block;
218 color: #00b4d8;
219 margin-bottom: 12px;
220 font-size: 14px;
221 font-weight: bold;
222}
223
224.text-input,
225.select-input {
226 width: 100%;
227 background: rgba(0, 180, 216, 0.1);
228 border: 2px solid #00b4d8;
229 border-radius: 8px;
230 padding: 12px 15px;
231 color: #00ff88;
232 font-size: 16px;
233 font-family: 'Courier New', monospace;
234 box-sizing: border-box;
235}
236
237.text-input:focus,
238.select-input:focus {
239 outline: none;
240 border-color: #00ff88;
241 box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
242}
243
244.radio-group,
245.checkbox-group {
246 display: flex;
247 flex-direction: column;
248 gap: 12px;
249}
250
251.radio-label,
252.checkbox-label {
253 display: flex;
254 align-items: center;
255 gap: 10px;
256 padding: 12px 15px;
257 background: rgba(0, 180, 216, 0.05);
258 border: 1px solid #00b4d8;
259 border-radius: 8px;
260 cursor: pointer;
261 transition: all 0.3s ease;
262}
263
264.radio-label:hover,
265.checkbox-label:hover {
266 background: rgba(0, 180, 216, 0.15);
267 border-color: #00ff88;
268}
269
270.radio-input,
271.checkbox-input {
272 width: 18px;
273 height: 18px;
274 cursor: pointer;
275 accent-color: #00ff88;
276}
277
278.radio-text,
279.checkbox-text {
280 color: #00ff88;
281 font-size: 14px;
282}
283
284.checkbox-label.single {
285 background: rgba(0, 255, 136, 0.1);
286 border-color: #00ff88;
287}
288
289.data-display {
290 margin-bottom: 30px;
291}
292
293.data-display h3 {
294 color: #00ff88;
295 margin-bottom: 15px;
296}
297
298.data-card {
299 background: rgba(0, 0, 0, 0.3);
300 border: 1px solid #00b4d8;
301 border-radius: 8px;
302 padding: 20px;
303}
304
305.data-row {
306 display: flex;
307 justify-content: space-between;
308 padding: 10px 0;
309 border-bottom: 1px solid rgba(0, 180, 216, 0.2);
310}
311
312.data-row:last-child {
313 border-bottom: none;
314}
315
316.data-label {
317 color: #00b4d8;
318 font-weight: bold;
319}
320
321.data-value {
322 color: #00ff88;
323 text-align: right;
324}
325
326.binding-info {
327 background: rgba(0, 255, 136, 0.05);
328 padding: 20px;
329 border-radius: 8px;
330 border: 1px solid #00ff88;
331}
332
333.binding-info h3 {
334 color: #00ff88;
335 margin-top: 0;
336}
337
338.binding-info ul {
339 margin: 0;
340 padding-left: 20px;
341}
342
343.binding-info li {
344 color: #00b4d8;
345 margin: 8px 0;
346}
347
348.binding-info code {
349 background: rgba(0, 255, 136, 0.2);
350 padding: 2px 6px;
351 border-radius: 4px;
352 color: #00ff88;
353 font-size: 12px;
354}
355</style>Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. When does v-model.lazy update the variable?
2. What does the .number modifier do?
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Create an input with .number for the budget and .trim for the mission name.
- Click in order
Click in the correct order to build v-model.number.lazy:
- Horizontal ordering
Arrange the correct syntax for an input with v-model.number: