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

Advanced Forms

6 min read
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. 1. How do you create a select with the ability to choose multiple options?

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

Useful articles