Vue.js course Β· Module 5: Events & Forms

v-model Modifiers - tuning the signal from the panels

10 min read
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>&lt;input type="text"&gt;</code> - String</li>
167        <li><code>&lt;input type="radio"&gt;</code> - Value of the selected radio</li>
168        <li><code>&lt;input type="checkbox"&gt;</code> - Boolean (single) or Array (multiple)</li>
169        <li><code>&lt;select&gt;</code> - Value of the selected option</li>
170        <li><code>&lt;textarea&gt;</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. 1. When does v-model.lazy update the variable?

  2. 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:

Useful articles