Vue.js course Β· Module 5: Events & Forms
Advanced Forms
In this lesson3
NOVA LAB engineers are registering a new station module, and the form from the previous lessons is no longer enough for them. They want to select several systems to monitor at once, pick a module from a catalog that changes every week, and keep a two-letter name or a deployment year of 1999 out of the database. Each of these needs is one new form element.
Multi-select
The multiple attribute turns a drop-down list into a multiple-choice list. v-model then writes an array of the selected option values into the variable, so the variable must start as an empty array. The join(', ') method glues the array into a readable string.
1<template>
2 <div>
3 <label>Select systems to monitor (Ctrl+Click):</label>
4 <select v-model="selectedSystems" multiple>
5 <option value="oxygen">Oxygen generator</option>
6 <option value="water">Water recycler</option>
7 <option value="power">Solar power</option>
8 <option value="comms">Communications</option>
9 </select>
10 <p>Selected: {{ selectedSystems.join(', ') }}</p>
11 </div>
12</template>
13
14<script setup>
15import { ref } from 'vue'
16
17const selectedSystems = ref([])
18</script>You select additional options while holding Ctrl, or Cmd on macOS. In the array the values appear in the order of the options in the list, not in the order of clicking, unlike with a checkbox group. The v-model syntax did not change, only the data type did. With three or four options, however, I recommend a checkbox group: it needs no hidden keyboard shortcut and shows all the choices at once.
Dynamic Options
The module catalog arrives as an array of objects with an id, a name and a sector. The options are generated by v-for, and :value="mod.id" writes the numeric id into the variable. The name of the selected module is looked up by a function using the find method, which returns the first element meeting a condition, or undefined. The ?. operator (optional chaining) reaches for name only when a module was found, and || '' turns a missing result into an empty string.
1<template>
2 <div>
3 <select v-model="selectedModule">
4 <option disabled value="">Select station module</option>
5 <option
6 v-for="mod in modules"
7 :key="mod.id"
8 :value="mod.id"
9 >
10 {{ mod.name }} ({{ mod.sector }})
11 </option>
12 </select>
13
14 <p v-if="selectedModule">
15 Selected: {{ getModuleName(selectedModule) }}
16 </p>
17 </div>
18</template>
19
20<script setup>
21import { ref } from 'vue'
22
23const modules = ref([
24 { id: 1, name: 'Oxygen Alpha', sector: 'Sector A' },
25 { id: 2, name: 'Plasma Beta', sector: 'Sector B' },
26 { id: 3, name: 'Water Gamma', sector: 'Sector C' }
27])
28
29const selectedModule = ref('')
30
31function getModuleName(id) {
32 return modules.value.find(m => m.id === id)?.name || ''
33}
34</script>Because :value preserves the type, selectedModule holds a number, and the strict comparison m.id === id works. The paragraph with v-if stays hidden as long as the variable holds an empty string. Adding a module to the modules array adds an option immediately, and the template does not need to be touched.
Form Validation
Validation answers three questions: what to check, when to check it and what to do with the result. Let us start with the template of the first two fields. Each has v-model, a @blur listener that runs validation after the field is left, and an error message that is visible only when it exists.
1<template>
2 <form @submit.prevent="submitForm">
3 <div :class="{ error: errors.name }">
4 <label>Module name:</label>
5 <input v-model="form.name" @blur="validateName" />
6 <span v-if="errors.name" class="error-message">
7 {{ errors.name }}
8 </span>
9 </div>
10
11 <div :class="{ error: errors.year }">
12 <label>Deployment year:</label>
13 <input v-model.number="form.year" type="number" @blur="validateYear" />
14 <span v-if="errors.year" class="error-message">
15 {{ errors.year }}
16 </span>
17 </div>:class="{ error: errors.name }" adds the error CSS class when the message is not empty, so the field can light up in red. The year uses v-model.number, and the engineer field in the next part uses v-model.trim - you know both modifiers from the previous lesson. The second part of the template finishes the form with a button.
1 <div :class="{ error: errors.engineer }">
2 <label>Responsible engineer:</label>
3 <input v-model.trim="form.engineer" @blur="validateEngineer" />
4 <span v-if="errors.engineer" class="error-message">
5 {{ errors.engineer }}
6 </span>
7 </div>
8
9 <button type="submit" :disabled="!isFormValid">
10 Register module
11 </button>
12 </form>
13</template>:disabled="!isFormValid" binds the attribute to a computed, so the button unlocks by itself once the form becomes valid. Without the colon, disabled="!isFormValid" would be plain text and the button would stay blocked forever, and v-if instead of :disabled would hide the button completely. Now the form state.
1<script setup>
2import { reactive, computed } from 'vue'
3
4const form = reactive({
5 name: '',
6 year: null,
7 engineer: ''
8})
9
10const errors = reactive({
11 name: '',
12 year: '',
13 engineer: ''
14})reactive creates a reactive object whose fields you access without .value. The form object holds the field values, and errors holds the messages, where an empty string means no error. Next come the validation functions.
1function validateName() {
2 if (!form.name) {
3 errors.name = 'Name is required'
4 } else if (form.name.length < 3) {
5 errors.name = 'Name must be at least 3 characters'
6 } else {
7 errors.name = ''
8 }
9}
10
11function validateYear() {
12 if (!form.year) {
13 errors.year = 'Year is required'
14 } else if (form.year < 2020 || form.year > 2100) {
15 errors.year = 'Year must be between 2020 and 2100'
16 } else {
17 errors.year = ''
18 }
19}
20
21function validateEngineer() {
22 errors.engineer = form.engineer ? '' : 'Engineer is required'
23}Each function checks one field and sets its message. The order of the conditions matters: first an empty field, then the details, such as the length of the name or the range of years from 2020 to 2100. Finally, the computed and the submit handling.
1const isFormValid = computed(() => {
2 return form.name && form.year && form.engineer &&
3 !errors.name && !errors.year && !errors.engineer
4})
5
6function submitForm() {
7 validateName()
8 validateYear()
9 validateEngineer()
10
11 if (isFormValid.value) {
12 console.log('Registration:', form)
13 alert('Module registered!')
14 }
15}
16</script>isFormValid recalculates automatically whenever the form data changes, which is why a computed suits this role better than a flag updated by hand. submitForm first runs all the validations, because the operator may have corrected a field and pressed Enter without leaving it, and in that case @blur has not fired yet. Then it reads isFormValid.value - the computed recalculates on that read, so it sees the messages set one line above. The complete flow looks like this: a click on the button, @submit.prevent calls the procedure, validation of all fields, sending the data when it is valid, and finally a success message or a form reset.
When should you validate? I recommend two moments: @blur, when the operator is done with a field, and @submit as the final check. Validating on every @input buries someone who has just started typing under error messages. In the next lesson you will assemble these elements into a complete registration form with field groups and a reset.
Remember: validation is the station's airlock - data moves on only when every sensor confirms it is safe.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4const missionData = ref({
5 title: '',
6 priority: '',
7 budget: '',
8 notes: ''
9})
10
11const errors = ref({
12 title: '',
13 priority: '',
14 budget: '',
15 notes: ''
16})
17
18const validateTitle = () => {
19 if (!missionData.value.title.trim()) {
20 errors.value.title = 'Title is required'
21 } else if (missionData.value.title.trim().length < 5) {
22 errors.value.title = 'Title must have at least 5 characters'
23 } else {
24 errors.value.title = ''
25 }
26}
27
28const validatePriority = () => {
29 const num = Number(missionData.value.priority)
30 if (isNaN(num) || num < 1 || num > 10) {
31 errors.value.priority = 'Priority must be a number from 1 to 10'
32 } else {
33 errors.value.priority = ''
34 }
35}
36
37const validateBudget = () => {
38 const num = Number(missionData.value.budget)
39 if (isNaN(num) || num <= 0) {
40 errors.value.budget = 'Budget must be a number greater than 0'
41 } else {
42 errors.value.budget = ''
43 }
44}
45
46const validateNotes = () => {
47 const trimmed = missionData.value.notes.trim()
48 if (trimmed.length > 0 && trimmed.length < 10) {
49 errors.value.notes = 'Notes must have at least 10 characters or be empty'
50 } else {
51 errors.value.notes = ''
52 }
53}
54
55const isFormValid = computed(() => {
56 return !errors.value.title &&
57 !errors.value.priority &&
58 !errors.value.budget &&
59 !errors.value.notes &&
60 missionData.value.title.trim() &&
61 missionData.value.priority &&
62 missionData.value.budget
63})
64
65const budgetFormatted = computed(() => {
66 if (!missionData.value.budget) return ''
67 return `$${Number(missionData.value.budget).toLocaleString()}`
68})
69</script>
70
71<template>
72 <div class="validation-center">
73 <div class="center-header">
74 <h2>Mission Validation Center</h2>
75 <p class="subtitle">v-model Modifiers and Form Validation</p>
76 </div>
77
78 <div class="form-container">
79 <!-- .trim modifier -->
80 <div class="form-group">
81 <label for="title">Mission Title (.trim):</label>
82 <input
83 id="title"
84 v-model.trim="missionData.title"
85 @blur="validateTitle"
86 type="text"
87 placeholder="e.g. Gale Crater Exploration"
88 class="form-input"
89 :class="{ error: errors.title }"
90 />
91 <div v-if="errors.title" class="error-message">
92 {{ errors.title }}
93 </div>
94 <div v-else class="help-text">
95 Spaces at the beginning and end will be removed automatically
96 </div>
97 </div>
98
99 <!-- .number modifier -->
100 <div class="form-group">
101 <label for="priority">Mission Priority (.number):</label>
102 <input
103 id="priority"
104 v-model.number="missionData.priority"
105 @blur="validatePriority"
106 type="text"
107 placeholder="1-10"
108 class="form-input"
109 :class="{ error: errors.priority }"
110 />
111 <div v-if="errors.priority" class="error-message">
112 {{ errors.priority }}
113 </div>
114 <div v-else class="help-text">
115 Value automatically converted to a number
116 </div>
117 <div class="type-display">
118 Data type: <code>{{ typeof missionData.priority }}</code>
119 </div>
120 </div>
121
122 <!-- .number with formatting -->
123 <div class="form-group">
124 <label for="budget">Mission Budget (.number):</label>
125 <input
126 id="budget"
127 v-model.number="missionData.budget"
128 @blur="validateBudget"
129 type="text"
130 placeholder="e.g. 1000000"
131 class="form-input"
132 :class="{ error: errors.budget }"
133 />
134 <div v-if="errors.budget" class="error-message">
135 {{ errors.budget }}
136 </div>
137 <div v-else-if="missionData.budget" class="success-text">
138 Formatted budget: {{ budgetFormatted }}
139 </div>
140 </div>
141
142 <!-- .lazy modifier -->
143 <div class="form-group">
144 <label for="notes">Notes (.lazy):</label>
145 <textarea
146 id="notes"
147 v-model.lazy="missionData.notes"
148 @change="validateNotes"
149 placeholder="Additional information... (updates on blur)"
150 class="form-textarea"
151 :class="{ error: errors.notes }"
152 rows="4"
153 ></textarea>
154 <div v-if="errors.notes" class="error-message">
155 {{ errors.notes }}
156 </div>
157 <div v-else class="help-text">
158 Data updates only after leaving the field (blur/change)
159 </div>
160 </div>
161
162 <div class="validation-summary" :class="{ valid: isFormValid }">
163 <div v-if="isFormValid" class="valid-message">
164 Form is valid and ready to submit!
165 </div>
166 <div v-else class="invalid-message">
167 Form contains errors or incomplete data
168 </div>
169 </div>
170 </div>
171
172 <div class="data-preview">
173 <h3>Mission Data Preview</h3>
174 <div class="preview-container">
175 <div class="preview-item">
176 <span class="preview-label">Title:</span>
177 <span class="preview-value">{{ missionData.title || '(empty)' }}</span>
178 </div>
179 <div class="preview-item">
180 <span class="preview-label">Priority:</span>
181 <span class="preview-value">
182 {{ missionData.priority || '(empty)' }}
183 (type: {{ typeof missionData.priority }})
184 </span>
185 </div>
186 <div class="preview-item">
187 <span class="preview-label">Budget:</span>
188 <span class="preview-value">
189 {{ missionData.budget || '(empty)' }}
190 (type: {{ typeof missionData.budget }})
191 </span>
192 </div>
193 <div class="preview-item">
194 <span class="preview-label">Notes:</span>
195 <span class="preview-value">{{ missionData.notes || '(empty)' }}</span>
196 </div>
197 </div>
198 </div>
199
200 <div class="modifiers-reference">
201 <h3>v-model Modifiers:</h3>
202 <div class="modifier-cards">
203 <div class="modifier-card">
204 <h4>.lazy</h4>
205 <p>Synchronizes data on the 'change' event instead of 'input'</p>
206 <code>v-model.lazy="variable"</code>
207 </div>
208 <div class="modifier-card">
209 <h4>.number</h4>
210 <p>Automatically converts the value to Number</p>
211 <code>v-model.number="variable"</code>
212 </div>
213 <div class="modifier-card">
214 <h4>.trim</h4>
215 <p>Automatically removes whitespace from the beginning and end</p>
216 <code>v-model.trim="variable"</code>
217 </div>
218 </div>
219 </div>
220 </div>
221</template>
222
223<style scoped>
224.validation-center {
225 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
226 color: #00ff88;
227 padding: 30px;
228 border-radius: 15px;
229 border: 2px solid #00b4d8;
230 font-family: 'Courier New', monospace;
231 max-width: 900px;
232 margin: 0 auto;
233}
234
235.center-header {
236 text-align: center;
237 margin-bottom: 30px;
238 padding-bottom: 20px;
239 border-bottom: 2px solid #00b4d8;
240}
241
242.center-header h2 {
243 color: #00ff88;
244 margin: 0 0 10px 0;
245 font-size: 28px;
246 text-shadow: 0 0 10px #00ff88;
247}
248
249.subtitle {
250 color: #00b4d8;
251 margin: 0;
252 font-size: 14px;
253}
254
255.form-container {
256 margin-bottom: 30px;
257}
258
259.form-group {
260 margin-bottom: 25px;
261}
262
263.form-group label {
264 display: block;
265 color: #00b4d8;
266 margin-bottom: 10px;
267 font-size: 14px;
268 font-weight: bold;
269}
270
271.form-input,
272.form-textarea {
273 width: 100%;
274 background: rgba(0, 180, 216, 0.1);
275 border: 2px solid #00b4d8;
276 border-radius: 8px;
277 padding: 12px 15px;
278 color: #00ff88;
279 font-size: 16px;
280 font-family: 'Courier New', monospace;
281 box-sizing: border-box;
282 transition: all 0.3s ease;
283}
284
285.form-input:focus,
286.form-textarea:focus {
287 outline: none;
288 border-color: #00ff88;
289 box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
290}
291
292.form-input.error,
293.form-textarea.error {
294 border-color: #ff6b35;
295 background: rgba(255, 107, 53, 0.05);
296}
297
298.form-textarea {
299 resize: vertical;
300}
301
302.error-message {
303 margin-top: 8px;
304 color: #ff6b35;
305 font-size: 12px;
306 padding: 8px 12px;
307 background: rgba(255, 107, 53, 0.1);
308 border-radius: 4px;
309 border-left: 3px solid #ff6b35;
310}
311
312.help-text {
313 margin-top: 8px;
314 color: #00b4d8;
315 font-size: 12px;
316 padding: 8px 12px;
317 background: rgba(0, 180, 216, 0.05);
318 border-radius: 4px;
319}
320
321.success-text {
322 margin-top: 8px;
323 color: #00ff88;
324 font-size: 12px;
325 padding: 8px 12px;
326 background: rgba(0, 255, 136, 0.1);
327 border-radius: 4px;
328 font-weight: bold;
329}
330
331.type-display {
332 margin-top: 8px;
333 color: #9d4edd;
334 font-size: 11px;
335}
336
337.type-display code {
338 background: rgba(157, 78, 221, 0.2);
339 padding: 2px 6px;
340 border-radius: 4px;
341 color: #9d4edd;
342}
343
344.validation-summary {
345 padding: 20px;
346 background: rgba(255, 107, 53, 0.1);
347 border: 2px solid #ff6b35;
348 border-radius: 8px;
349 text-align: center;
350 font-weight: bold;
351 transition: all 0.3s ease;
352}
353
354.validation-summary.valid {
355 background: rgba(0, 255, 136, 0.1);
356 border-color: #00ff88;
357}
358
359.valid-message {
360 color: #00ff88;
361 font-size: 16px;
362}
363
364.invalid-message {
365 color: #ff6b35;
366 font-size: 16px;
367}
368
369.data-preview {
370 margin-bottom: 30px;
371}
372
373.data-preview h3 {
374 color: #00ff88;
375 margin-bottom: 15px;
376}
377
378.preview-container {
379 background: rgba(0, 0, 0, 0.3);
380 border: 1px solid #00b4d8;
381 border-radius: 8px;
382 padding: 20px;
383}
384
385.preview-item {
386 padding: 10px 0;
387 border-bottom: 1px solid rgba(0, 180, 216, 0.2);
388 display: flex;
389 justify-content: space-between;
390 gap: 15px;
391}
392
393.preview-item:last-child {
394 border-bottom: none;
395}
396
397.preview-label {
398 color: #00b4d8;
399 font-weight: bold;
400}
401
402.preview-value {
403 color: #00ff88;
404 text-align: right;
405 word-break: break-word;
406}
407
408.modifiers-reference {
409 background: rgba(0, 255, 136, 0.05);
410 padding: 20px;
411 border-radius: 8px;
412 border: 1px solid #00ff88;
413}
414
415.modifiers-reference h3 {
416 color: #00ff88;
417 margin-top: 0;
418}
419
420.modifier-cards {
421 display: grid;
422 grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
423 gap: 15px;
424}
425
426.modifier-card {
427 background: rgba(0, 180, 216, 0.1);
428 padding: 15px;
429 border-radius: 8px;
430 border: 1px solid #00b4d8;
431}
432
433.modifier-card h4 {
434 color: #00ff88;
435 margin: 0 0 10px 0;
436}
437
438.modifier-card p {
439 color: #00b4d8;
440 font-size: 12px;
441 margin: 0 0 10px 0;
442}
443
444.modifier-card code {
445 display: block;
446 background: rgba(0, 0, 0, 0.3);
447 padding: 8px;
448 border-radius: 4px;
449 color: #00ff88;
450 font-size: 11px;
451}
452</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. How do you create a select with the ability to choose multiple options?
2. Why is computed good for checking if a form is valid?
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Add validation on @blur with error messages and a computed isFormValid.
- Vertical ordering
Arrange the form validation steps from top to bottom:
- Click in order
Arrange the syntax for a button with dynamic disabled:
- Code editor
Build a select with v-for rendering options from an array of objects and display the selected value.
- Vertical ordering
Arrange the correct form submit flow: