Vue.js course Β· Module 5: Events & Forms
Forms in Practice - Module Registration
The NOVA LAB engineering department has received an order: one form in which every new station module gets registered, from the airlock to the laboratory. A module has a name, a type, a year and a status, technical parameters, categories, a description and service options. A dozen fields dumped one under another make a wall that nobody will read. So we will build a form divided into groups, with fields that appear only when they make sense, with a reset and a data preview.
Field groups: fieldset and legend
The <fieldset> element groups related fields, and <legend> is the caption of that group. Screen readers read it out before the fields, so an operator using a screen reader knows which section they are in. A <label> with the for attribute points to the id of a field, so clicking the caption puts the cursor in the field. The first group starts with the module name and type.
1<template>
2 <form @submit.prevent="submitModule" class="module-form" style="background: #0a0e27; color: #00ff88;">
3 <h2>Mission module registration</h2>
4
5 <!-- Basic information -->
6 <fieldset style="border-color: #00b4d8;">
7 <legend>Basic information</legend>
8
9 <div class="form-group">
10 <label for="name">Module name *</label>
11 <input
12 id="name"
13 v-model.trim="module.name"
14 required
15 minlength="3"
16 style="background: #1a1e37; color: #00ff88;"
17 />
18 </div>
19
20 <div class="form-group">
21 <label for="type">Type *</label>
22 <input id="type" v-model.trim="module.type" required style="background: #1a1e37; color: #00ff88;" />
23 </div>The name has the required and minlength="3" attributes, which is built-in HTML validation. The browser checks such attributes before it even dispatches the submit event, and on an error it shows its own message - @submit.prevent does not switch any of this off. v-model.trim also removes unnecessary spaces. Next comes the row with the year and the status.
1 <div class="form-row">
2 <div class="form-group">
3 <label for="year">Deployment year</label>
4 <input
5 id="year"
6 v-model.number="module.year"
7 type="number"
8 min="2020"
9 max="2100"
10 style="background: #1a1e37; color: #00ff88;"
11 />
12 </div>
13
14 <div class="form-group">
15 <label for="status">Status</label>
16 <select id="status" v-model="module.status" style="background: #1a1e37; color: #00ff88;">
17 <option value="">Select...</option>
18 <option value="ACTIVE">Active</option>
19 <option value="STANDBY">Standby</option>
20 <option value="MAINTENANCE">Maintenance</option>
21 <option value="OFFLINE">Offline</option>
22 </select>
23 </div>
24 </div>
25 </fieldset>The year has min and max for the browser and .number for Vue. The isValid computed does not check the year, so an entry of 1999 is stopped only by the browser after the submit button is clicked. The status list starts with an empty option that prompts a choice. The second group shows that v-model also works on nested paths such as module.params.maxPower.
1 <!-- Parameters -->
2 <fieldset style="border-color: #00b4d8;">
3 <legend>Parameters</legend>
4 <div class="form-row">
5 <div class="form-group">
6 <label>Max power (MW)</label>
7 <input v-model.number="module.params.maxPower" type="number" style="background: #1a1e37; color: #00ff88;" />
8 </div>
9 <div class="form-group">
10 <label>Temperature (K)</label>
11 <input v-model.number="module.params.temperature" type="number" style="background: #1a1e37; color: #00ff88;" />
12 </div>
13 <div class="form-group" v-if="needsPressure">
14 <label>Pressure (kPa)</label>
15 <input v-model.number="module.params.pressure" type="number" style="background: #1a1e37; color: #00ff88;" />
16 </div>
17 </div>
18 </fieldset>The pressure field has v-if="needsPressure", so it exists only when the computed returns true. With false, Vue removes it from the DOM rather than just hiding it. Removing the field does not clear the data, though: a pressure typed earlier stays in module.params.pressure. The categories in the third group are generated by v-for from an array.
1 <!-- Categories -->
2 <fieldset>
3 <legend>Categories</legend>
4 <div class="checkbox-group">
5 <label v-for="cat in availableCategories" :key="cat.value">
6 <input
7 type="checkbox"
8 v-model="module.categories"
9 :value="cat.value"
10 />
11 {{ cat.label }}
12 </label>
13 </div>
14 </fieldset>Each checkbox takes its value from :value="cat.value", and all of them write into one array, module.categories, exactly like the checkbox group from the v-model lesson. The fourth group is the description in a text area.
1 <!-- Description -->
2 <fieldset>
3 <legend>Description</legend>
4 <textarea
5 v-model="module.description"
6 rows="4"
7 placeholder="Describe the mission module..."
8 ></textarea>
9 <p class="char-count">
10 {{ module.description.length }} / 500 characters
11 </p>
12 </fieldset>The rows="4" attribute sets the visible height of the field. The counter below only reports the length of the description and limits nothing: a hard limit would require the maxlength="500" attribute. The fifth group holds the options and a conditional field.
1 <!-- Options -->
2 <fieldset>
3 <legend>Options</legend>
4 <label class="checkbox-label">
5 <input type="checkbox" v-model="module.isActive" />
6 Module active
7 </label>
8 <label class="checkbox-label">
9 <input type="checkbox" v-model="module.needsMaintenance" />
10 Requires maintenance
11 </label>
12
13 <div v-if="module.needsMaintenance" class="form-group">
14 <label>Maintenance budget (USD)</label>
15 <input v-model.number="module.maintenanceCost" type="number" min="0" />
16 </div>
17 </fieldset>This is the conditional field pattern: a checkbox with v-model controls a variable, and v-if on that variable shows an extra field with the maintenance budget. isActive starts as true, so the box is checked from the start, because v-model takes its state from JavaScript. At the end of the template come the buttons and the data preview.
1 <!-- Buttons -->
2 <div class="form-actions">
3 <button type="button" @click="resetForm">Clear</button>
4 <button type="submit" :disabled="!isValid">Register module</button>
5 </div>
6
7 <!-- Preview -->
8 <details>
9 <summary>Data preview</summary>
10 <pre>{{ JSON.stringify(module, null, 2) }}</pre>
11 </details>
12 </form>
13</template>type="button" on the clear button is crucial, because a button inside a form has the submit type by default, and without this attribute clearing would submit the form. <details> with <summary> creates an expandable preview, and JSON.stringify(module, null, 2) formats the data with a two-space indent. Let us move on to the script.
1<script setup>
2import { reactive, computed } from 'vue'
3
4const initialState = {
5 name: '',
6 type: '',
7 year: null,
8 status: '',
9 params: { maxPower: null, temperature: null, pressure: null },
10 categories: [],
11 description: '',
12 isActive: true,
13 needsMaintenance: false,
14 maintenanceCost: null
15}
16
17const module = reactive({
18 ...initialState,
19 params: { ...initialState.params },
20 categories: []
21})initialState describes the empty form, and reactive turns it into state. Beware of a trap: spreading ...initialState copies only the first level of the object. If params did not get a separate copy, typing in a power value would also change initialState.params, and the reset would restore the typed numbers instead of empty fields. That is why params and categories get copies of their own. Next, the category list and two computed values.
1const availableCategories = [
2 { value: 'lifesupport', label: 'Life support' },
3 { value: 'energy', label: 'Energy' },
4 { value: 'research', label: 'Research' },
5 { value: 'communication', label: 'Communication' },
6 { value: 'navigation', label: 'Navigation' }
7]
8
9const needsPressure = computed(() => {
10 return ['lifesupport', 'research'].some(c => module.categories.includes(c))
11})
12
13const isValid = computed(() => {
14 return module.name.length >= 3 && module.type
15})availableCategories is a plain array, because it never changes and needs no reactivity. needsPressure uses the some method, which returns true when at least one element meets the condition. isValid plays the role of isFormValid from the previous lesson: it requires a name of at least three characters and a non-empty type. What remains is the reset and the submission.
1function resetForm() {
2 Object.assign(module, {
3 ...initialState,
4 params: { ...initialState.params },
5 categories: []
6 })
7}
8
9function submitModule() {
10 if (isValid.value) {
11 console.log('Registering:', JSON.parse(JSON.stringify(module)))
12 alert('Module has been registered!')
13 }
14}
15</script>Object.assign writes the initial values into the existing module object, so the template stays connected to the same state. Replacing the whole object with a new one would cut the template off from the data. params and categories get fresh copies again. JSON.parse(JSON.stringify(module)) gives the console a plain snapshot of the data instead of a reactive object. I recommend going one step further and turning initialState into a function that returns a new object on every call - the shallow copy trap then disappears completely. In the module project you will use the same order: state, v-model, validation on @blur, the isFormValid computed and @submit.prevent.
Remember: a good registration form works like a well-designed station module - every group has its own compartment, and the reset restores the factory settings without leaving a trace.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4const missionForm = ref({
5 commander: '',
6 destination: '',
7 duration: '',
8 crewSize: '',
9 missionType: 'exploration',
10 equipment: [],
11 emergencyProtocol: false,
12 launchDate: '',
13 objectives: ''
14})
15
16const errors = ref({})
17const submitted = ref(false)
18const submittedData = ref(null)
19
20const missionTypes = [
21 { value: 'exploration', label: 'Exploration' },
22 { value: 'research', label: 'Research' },
23 { value: 'construction', label: 'Construction' },
24 { value: 'rescue', label: 'Rescue' }
25]
26
27const equipmentOptions = [
28 { id: 'rover', name: 'Rover' },
29 { id: 'drill', name: 'Drill' },
30 { id: 'lab', name: 'Laboratory' },
31 { id: 'satellite', name: 'Satellite' },
32 { id: 'drone', name: 'Drone' }
33]
34
35const validateForm = () => {
36 errors.value = {}
37
38 if (!missionForm.value.commander.trim()) {
39 errors.value.commander = 'Commander is required'
40 }
41
42 if (!missionForm.value.destination.trim()) {
43 errors.value.destination = 'Destination is required'
44 }
45
46 const duration = Number(missionForm.value.duration)
47 if (!duration || duration < 1 || duration > 365) {
48 errors.value.duration = 'Duration: 1-365 days'
49 }
50
51 const crew = Number(missionForm.value.crewSize)
52 if (!crew || crew < 1 || crew > 50) {
53 errors.value.crewSize = 'Crew: 1-50 people'
54 }
55
56 if (!missionForm.value.launchDate) {
57 errors.value.launchDate = 'Launch date is required'
58 }
59
60 if (missionForm.value.objectives.trim().length < 20) {
61 errors.value.objectives = 'Objectives must have at least 20 characters'
62 }
63
64 return Object.keys(errors.value).length === 0
65}
66
67const handleSubmit = () => {
68 if (validateForm()) {
69 submitted.value = true
70 submittedData.value = {
71 ...missionForm.value,
72 submittedAt: new Date().toLocaleString('en-US')
73 }
74
75 // Submission simulation
76 setTimeout(() => {
77 resetForm()
78 }, 5000)
79 }
80}
81
82const resetForm = () => {
83 missionForm.value = {
84 commander: '',
85 destination: '',
86 duration: '',
87 crewSize: '',
88 missionType: 'exploration',
89 equipment: [],
90 emergencyProtocol: false,
91 launchDate: '',
92 objectives: ''
93 }
94 errors.value = {}
95 submitted.value = false
96 submittedData.value = null
97}
98
99const isFormValid = computed(() => {
100 return missionForm.value.commander.trim() &&
101 missionForm.value.destination.trim() &&
102 missionForm.value.duration &&
103 missionForm.value.crewSize &&
104 missionForm.value.launchDate &&
105 missionForm.value.objectives.trim().length >= 20
106})
107</script>
108
109<template>
110 <div class="mission-control">
111 <div class="control-header">
112 <h2>NOVA LAB Mission Control Center</h2>
113 <p class="subtitle">Complete Mission Registration System</p>
114 </div>
115
116 <div v-if="submitted" class="success-panel">
117 <div class="success-message">
118 <h3>Mission Successfully Registered!</h3>
119 <p>Data has been sent to the NOVA LAB Command Center</p>
120 </div>
121
122 <div class="submitted-data">
123 <h4>Mission Details:</h4>
124 <div class="data-grid">
125 <div><strong>Commander:</strong> {{ submittedData.commander }}</div>
126 <div><strong>Destination:</strong> {{ submittedData.destination }}</div>
127 <div><strong>Duration:</strong> {{ submittedData.duration }} days</div>
128 <div><strong>Crew:</strong> {{ submittedData.crewSize }} people</div>
129 <div><strong>Type:</strong> {{ missionTypes.find(t => t.value === submittedData.missionType)?.label }}</div>
130 <div><strong>Equipment:</strong> {{ submittedData.equipment.length }} items</div>
131 <div><strong>Launch date:</strong> {{ submittedData.launchDate }}</div>
132 <div><strong>Sent:</strong> {{ submittedData.submittedAt }}</div>
133 </div>
134 </div>
135
136 <button @click="resetForm" class="reset-btn">
137 New Mission
138 </button>
139 </div>
140
141 <form v-else @submit.prevent="handleSubmit" class="mission-form">
142 <div class="form-section">
143 <h3>Basic Information</h3>
144
145 <div class="form-field">
146 <label for="commander">Mission Commander:</label>
147 <input
148 id="commander"
149 v-model.trim="missionForm.commander"
150 type="text"
151 placeholder="Full name"
152 class="form-input"
153 :class="{ error: errors.commander }"
154 />
155 <span v-if="errors.commander" class="error-text">{{ errors.commander }}</span>
156 </div>
157
158 <div class="form-field">
159 <label for="destination">Destination:</label>
160 <input
161 id="destination"
162 v-model.trim="missionForm.destination"
163 type="text"
164 placeholder="e.g. Gale Crater, Mars"
165 class="form-input"
166 :class="{ error: errors.destination }"
167 />
168 <span v-if="errors.destination" class="error-text">{{ errors.destination }}</span>
169 </div>
170
171 <div class="form-row">
172 <div class="form-field">
173 <label for="duration">Duration (days):</label>
174 <input
175 id="duration"
176 v-model.number="missionForm.duration"
177 type="number"
178 placeholder="1-365"
179 class="form-input"
180 :class="{ error: errors.duration }"
181 />
182 <span v-if="errors.duration" class="error-text">{{ errors.duration }}</span>
183 </div>
184
185 <div class="form-field">
186 <label for="crew">Crew size:</label>
187 <input
188 id="crew"
189 v-model.number="missionForm.crewSize"
190 type="number"
191 placeholder="1-50"
192 class="form-input"
193 :class="{ error: errors.crewSize }"
194 />
195 <span v-if="errors.crewSize" class="error-text">{{ errors.crewSize }}</span>
196 </div>
197 </div>
198
199 <div class="form-field">
200 <label for="launch">Data startu:</label>
201 <input
202 id="launch"
203 v-model="missionForm.launchDate"
204 type="date"
205 class="form-input"
206 :class="{ error: errors.launchDate }"
207 />
208 <span v-if="errors.launchDate" class="error-text">{{ errors.launchDate }}</span>
209 </div>
210 </div>
211
212 <div class="form-section">
213 <h3>Mission Configuration</h3>
214
215 <div class="form-field">
216 <label>Mission type:</label>
217 <div class="radio-group">
218 <label
219 v-for="type in missionTypes"
220 :key="type.value"
221 class="radio-label"
222 >
223 <input
224 v-model="missionForm.missionType"
225 type="radio"
226 :value="type.value"
227 />
228 {{ type.label }}
229 </label>
230 </div>
231 </div>
232
233 <div class="form-field">
234 <label>Equipment:</label>
235 <div class="checkbox-group">
236 <label
237 v-for="item in equipmentOptions"
238 :key="item.id"
239 class="checkbox-label"
240 >
241 <input
242 v-model="missionForm.equipment"
243 type="checkbox"
244 :value="item.id"
245 />
246 {{ item.name }}
247 </label>
248 </div>
249 </div>
250
251 <div class="form-field">
252 <label class="checkbox-label emergency">
253 <input
254 v-model="missionForm.emergencyProtocol"
255 type="checkbox"
256 />
257 Activate Rescue Protocol
258 </label>
259 </div>
260 </div>
261
262 <div class="form-section">
263 <h3>Mission Objectives</h3>
264
265 <div class="form-field">
266 <label for="objectives">Detailed description of objectives:</label>
267 <textarea
268 id="objectives"
269 v-model.trim="missionForm.objectives"
270 placeholder="Describe the main goals and tasks of the mission (minimum 20 characters)..."
271 class="form-textarea"
272 :class="{ error: errors.objectives }"
273 rows="6"
274 ></textarea>
275 <div class="char-counter">
276 Characters: {{ missionForm.objectives.length }}
277 </div>
278 <span v-if="errors.objectives" class="error-text">{{ errors.objectives }}</span>
279 </div>
280 </div>
281
282 <div class="form-actions">
283 <button
284 type="submit"
285 class="submit-btn"
286 :class="{ disabled: !isFormValid }"
287 :disabled="!isFormValid"
288 >
289 Register Mission
290 </button>
291
292 <button
293 type="button"
294 @click="resetForm"
295 class="cancel-btn"
296 >
297 Cancel
298 </button>
299 </div>
300
301 <div class="form-status" :class="{ valid: isFormValid }">
302 {{ isFormValid ? 'Form ready to submit' : 'Fill in all required fields' }}
303 </div>
304 </form>
305 </div>
306</template>
307
308<style scoped>
309.mission-control {
310 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
311 color: #00ff88;
312 padding: 30px;
313 border-radius: 15px;
314 border: 2px solid #00b4d8;
315 font-family: 'Courier New', monospace;
316 max-width: 1000px;
317 margin: 0 auto;
318}
319
320.control-header {
321 text-align: center;
322 margin-bottom: 30px;
323 padding-bottom: 20px;
324 border-bottom: 2px solid #00b4d8;
325}
326
327.control-header h2 {
328 color: #00ff88;
329 margin: 0 0 10px 0;
330 font-size: 28px;
331 text-shadow: 0 0 10px #00ff88;
332}
333
334.subtitle {
335 color: #00b4d8;
336 margin: 0;
337 font-size: 14px;
338}
339
340.success-panel {
341 text-align: center;
342}
343
344.success-message {
345 background: rgba(0, 255, 136, 0.1);
346 border: 2px solid #00ff88;
347 border-radius: 12px;
348 padding: 30px;
349 margin-bottom: 30px;
350}
351
352.success-message h3 {
353 color: #00ff88;
354 margin: 0 0 10px 0;
355 font-size: 24px;
356}
357
358.success-message p {
359 color: #00b4d8;
360 margin: 0;
361}
362
363.submitted-data {
364 background: rgba(0, 180, 216, 0.05);
365 border: 1px solid #00b4d8;
366 border-radius: 8px;
367 padding: 20px;
368 margin-bottom: 20px;
369 text-align: left;
370}
371
372.submitted-data h4 {
373 color: #00ff88;
374 margin-top: 0;
375}
376
377.data-grid {
378 display: grid;
379 grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
380 gap: 15px;
381 color: #00b4d8;
382}
383
384.data-grid strong {
385 color: #00ff88;
386}
387
388.reset-btn {
389 background: linear-gradient(135deg, #00b4d8 0%, #0077b6 100%);
390 color: white;
391 border: none;
392 padding: 15px 40px;
393 border-radius: 8px;
394 font-size: 16px;
395 font-weight: bold;
396 cursor: pointer;
397 font-family: 'Courier New', monospace;
398}
399
400.mission-form {
401 display: flex;
402 flex-direction: column;
403 gap: 30px;
404}
405
406.form-section {
407 background: rgba(0, 180, 216, 0.05);
408 padding: 25px;
409 border-radius: 12px;
410 border: 1px solid #00b4d8;
411}
412
413.form-section h3 {
414 color: #00ff88;
415 margin: 0 0 20px 0;
416 font-size: 20px;
417}
418
419.form-field {
420 margin-bottom: 20px;
421}
422
423.form-field:last-child {
424 margin-bottom: 0;
425}
426
427.form-field > label {
428 display: block;
429 color: #00b4d8;
430 margin-bottom: 8px;
431 font-size: 14px;
432 font-weight: bold;
433}
434
435.form-input,
436.form-textarea {
437 width: 100%;
438 background: rgba(0, 180, 216, 0.1);
439 border: 2px solid #00b4d8;
440 border-radius: 8px;
441 padding: 12px 15px;
442 color: #00ff88;
443 font-size: 16px;
444 font-family: 'Courier New', monospace;
445 box-sizing: border-box;
446}
447
448.form-input:focus,
449.form-textarea:focus {
450 outline: none;
451 border-color: #00ff88;
452 box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
453}
454
455.form-input.error,
456.form-textarea.error {
457 border-color: #ff6b35;
458}
459
460.form-textarea {
461 resize: vertical;
462}
463
464.form-row {
465 display: grid;
466 grid-template-columns: 1fr 1fr;
467 gap: 15px;
468}
469
470.error-text {
471 display: block;
472 margin-top: 5px;
473 color: #ff6b35;
474 font-size: 12px;
475}
476
477.char-counter {
478 margin-top: 5px;
479 color: #00b4d8;
480 font-size: 12px;
481}
482
483.radio-group,
484.checkbox-group {
485 display: flex;
486 flex-direction: column;
487 gap: 10px;
488}
489
490.radio-label,
491.checkbox-label {
492 display: flex;
493 align-items: center;
494 gap: 10px;
495 padding: 10px 15px;
496 background: rgba(0, 180, 216, 0.05);
497 border: 1px solid #00b4d8;
498 border-radius: 6px;
499 cursor: pointer;
500 color: #00ff88;
501}
502
503.radio-label:hover,
504.checkbox-label:hover {
505 background: rgba(0, 180, 216, 0.15);
506}
507
508.checkbox-label.emergency {
509 background: rgba(255, 107, 53, 0.1);
510 border-color: #ff6b35;
511 color: #ff6b35;
512 font-weight: bold;
513}
514
515.form-actions {
516 display: flex;
517 gap: 15px;
518 justify-content: center;
519}
520
521.submit-btn,
522.cancel-btn {
523 padding: 15px 40px;
524 border: none;
525 border-radius: 8px;
526 font-size: 16px;
527 font-weight: bold;
528 cursor: pointer;
529 font-family: 'Courier New', monospace;
530 transition: all 0.3s ease;
531}
532
533.submit-btn {
534 background: linear-gradient(135deg, #00ff88 0%, #00b368 100%);
535 color: #0a0e27;
536}
537
538.submit-btn:hover:not(.disabled) {
539 transform: translateY(-2px);
540 box-shadow: 0 5px 20px rgba(0, 255, 136, 0.4);
541}
542
543.submit-btn.disabled {
544 opacity: 0.5;
545 cursor: not-allowed;
546}
547
548.cancel-btn {
549 background: linear-gradient(135deg, #ff6b35 0%, #d64545 100%);
550 color: white;
551}
552
553.cancel-btn:hover {
554 transform: translateY(-2px);
555 box-shadow: 0 5px 20px rgba(255, 107, 53, 0.4);
556}
557
558.form-status {
559 text-align: center;
560 padding: 15px;
561 background: rgba(255, 107, 53, 0.1);
562 border: 2px solid #ff6b35;
563 border-radius: 8px;
564 color: #ff6b35;
565 font-weight: bold;
566}
567
568.form-status.valid {
569 background: rgba(0, 255, 136, 0.1);
570 border-color: #00ff88;
571 color: #00ff88;
572}
573</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. What is the best way to reset a form in Vue?
2. How do you show an additional field only when a checkbox is checked?
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
Arrange the syntax for conditionally displaying a field with v-if:
- Code editor
Build a form with fieldset, validation, reset, and submit.
- Horizontal ordering
Arrange the correct syntax for fieldset with legend:
- Click in order
Arrange the syntax for a form with a submit handler:
- Code editor
Build a form with @blur validation, a reset button, and disabled submit.
- Vertical ordering
Arrange the steps for creating a reactive form in Vue:
- Click in order
Arrange the definition of a computed that checks the form:
- Horizontal ordering
Arrange the correct syntax for a textarea with v-model: