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

Forms in Practice - Module Registration

7 min read

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. 1. What is the best way to reset a form in Vue?

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

Useful articles