Vue.js course Β· Module 5: Events & Forms
Forms - v-model for Mission Data
In this lesson4
The crew registration panel has a name field and a paragraph that repeats the name. For this to work, two connections are needed: the variable has to reach the field, and whatever the operator types has to get back into the variable. Writing that by hand for each of ten fields means twenty bindings and twenty chances for a mistake. Vue closes both directions in a single directive: v-model, a two-way data binding between a form field and a variable.
What v-model does under the hood
v-model is a shorthand. On a text field it expands into two things you already know: the :value binding, which writes the variable into the field, and the @input listener, which after every change reads $event.target.value and saves the result into the variable.
1<template>
2 <input :value="crewName" @input="crewName = $event.target.value" />
3 <input v-model="crewName" />
4</template>
5
6<script setup>
7import { ref } from 'vue'
8
9const crewName = ref('')
10</script>Both fields work identically and stay in sync with each other: type in one, and the text appears in the other, because both read and write the same ref. crewName remains an ordinary ref, so v-model needs no special state. Remember one rule: v-model ignores the value, checked and selected attributes written in the HTML. The source of truth is always the variable, which is why you set the initial value in JavaScript.
v-model Basics
The same directive works on every form field, but under the hood it picks the property and the event that fit the field type. Text fields and <textarea> use value and the input event, checkboxes and radio buttons use checked and change, and <select> uses value and change. Let us walk through the mission form in stages, starting with text.
1<template>
2 <div class="mission-form" style="background: #0a0e27; color: #00ff88;">
3 <!-- Text input -->
4 <input v-model="crewName" placeholder="Crew name" style="background: #1a1e37; color: #00ff88;" />
5 <p>Crew: {{ crewName }}</p>
6
7 <!-- Textarea -->
8 <textarea v-model="missionNotes" placeholder="Mission notes" style="background: #1a1e37; color: #00ff88;"></textarea>
9 <p>Notes: {{ missionNotes }}</p>The field and the text area behave the same way: the paragraph below them changes with every letter. Do not put {{ missionNotes }} between the <textarea> tags, because interpolation does not work in that spot. Always attach v-model to a text area. Now the checkboxes.
1 <!-- Checkbox - boolean -->
2 <label style="color: #00ff88;">
3 <input type="checkbox" v-model="isAutomated" />
4 Automatic mode
5 </label>
6 <p>Automatic: {{ isAutomated }}</p>
7
8 <!-- Checkbox - array -->
9 <label style="color: #00ff88;">
10 <input type="checkbox" v-model="activeSystems" value="oxygen" />
11 Oxygen generator
12 </label>
13 <label style="color: #00ff88;">
14 <input type="checkbox" v-model="activeSystems" value="water" />
15 Water recycler
16 </label>
17 <p>Active systems: {{ activeSystems }}</p>A single checkbox bound to isAutomated produces a boolean value: true when checked and false when unchecked. When several checkboxes share one array, each has its own value attribute, and activeSystems collects the values of the checked boxes, in the order they were checked. Time for the radio buttons and the drop-down list.
1 <!-- Radio -->
2 <label style="color: #00ff88;">
3 <input type="radio" v-model="powerMode" value="eco" />
4 Eco
5 </label>
6 <label style="color: #00ff88;">
7 <input type="radio" v-model="powerMode" value="performance" />
8 Performance
9 </label>
10 <p>Power mode: {{ powerMode }}</p>
11
12 <!-- Select -->
13 <select v-model="selectedModule" style="background: #1a1e37; color: #00ff88;">
14 <option disabled value="">Select module</option>
15 <option value="alpha">Oxygen Alpha</option>
16 <option value="beta">Plasma Beta</option>
17 <option value="gamma">Water Gamma</option>
18 </select>
19 <p>Module: {{ selectedModule }}</p>
20 </div>
21</template>The radio group writes the value of the selected button into powerMode, so the variable always holds a single value. <select> does the same with the chosen option. The first option with disabled and an empty value is the recommended pattern: when the variable starts as an empty string, the list shows a prompt to choose, and on iOS it does not block choosing the first real option. The script is all that is left.
1<script setup>
2import { ref } from 'vue'
3
4const crewName = ref('')
5const missionNotes = ref('')
6const isAutomated = ref(false)
7const activeSystems = ref([])
8const powerMode = ref('')
9const selectedModule = ref('')
10</script>Every field gets a starting value that matches its type: an empty string for the text fields, the radio group and the list, false for the single checkbox and an empty array for the checkbox group. A type mistake, such as ref('') for a checkbox group, is the most common cause of strange form behavior.
Select options from an array
The catalog of station modules keeps growing, so options are not typed by hand. You generate them with the v-for directive from an array of objects, and you provide the option value through the :value binding. As with any v-for list, every option also gets a unique :key.
1<template>
2 <select v-model="selectedModuleId">
3 <option disabled value="">Select a module</option>
4 <option v-for="mod in modules" :key="mod.id" :value="mod.id">
5 {{ mod.name }}
6 </option>
7 </select>
8 <p>Selected id: {{ selectedModuleId }}</p>
9</template>
10
11<script setup>
12import { ref } from 'vue'
13
14const modules = ref([
15 { id: 1, name: 'Oxygen Alpha' },
16 { id: 2, name: 'Plasma Beta' }
17])
18const selectedModuleId = ref('')
19</script>After you pick Plasma Beta, the paragraph shows 2. Pay attention to the type: thanks to :value, the variable receives the number 2, exactly the one from the array, while a plain value="2" attribute would always produce text. The :value binding accepts any value, even a whole object.
From data to submission
Every form is built in the same order: first the state in ref or reactive, then binding the fields with v-model, next @submit.prevent on the form, and finally validation before sending. You already know the first three steps, and we will add validation in the lesson on advanced forms. I recommend giving variables names that say what they store rather than what kind of field they belong to: activeSystems survives replacing the checkboxes with a multi-select list, checkboxValues does not. In the next lesson you will tune the signal from the fields with the .lazy, .number and .trim modifiers.
Remember: v-model is a two-way communication channel - the field on the panel and the variable in the mission computer always show the same value, no matter which side changed it.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4const astronautName = ref('')
5const missionCode = ref('')
6const missionDescription = ref('')
7const statusReport = ref('')
8
9const characterCount = computed(() => missionDescription.value.length)
10const wordCount = computed(() => {
11 return missionDescription.value.trim().split(/\s+/).filter(word => word.length > 0).length
12})
13
14const isFormValid = computed(() => {
15 return astronautName.value.length >= 3 &&
16 missionCode.value.length >= 4 &&
17 missionDescription.value.length >= 10
18})
19</script>
20
21<template>
22 <div class="data-transmission">
23 <div class="transmission-header">
24 <h2>Data Transmission System</h2>
25 <p class="subtitle">v-model - Two-way Data Binding</p>
26 </div>
27
28 <div class="form-container">
29 <div class="form-group">
30 <label for="astronaut">Astronaut Name:</label>
31 <input
32 id="astronaut"
33 v-model="astronautName"
34 type="text"
35 placeholder="Enter name..."
36 class="data-input"
37 />
38 <div class="input-feedback">
39 Entered: <strong>{{ astronautName || '(empty)' }}</strong>
40 </div>
41 </div>
42
43 <div class="form-group">
44 <label for="mission-code">Mission Code:</label>
45 <input
46 id="mission-code"
47 v-model="missionCode"
48 type="text"
49 placeholder="e.g. MARS-2087-A1"
50 class="data-input"
51 />
52 <div class="input-feedback">
53 Code: <strong>{{ missionCode || '(empty)' }}</strong>
54 </div>
55 </div>
56
57 <div class="form-group">
58 <label for="description">Mission Description:</label>
59 <textarea
60 id="description"
61 v-model="missionDescription"
62 placeholder="Describe mission details..."
63 class="data-textarea"
64 rows="5"
65 ></textarea>
66 <div class="textarea-stats">
67 <span>Characters: {{ characterCount }}</span>
68 <span>Words: {{ wordCount }}</span>
69 </div>
70 </div>
71
72 <div class="form-group">
73 <label for="status">Status Report (optional):</label>
74 <textarea
75 id="status"
76 v-model="statusReport"
77 placeholder="Additional information..."
78 class="data-textarea"
79 rows="3"
80 ></textarea>
81 </div>
82
83 <div class="validation-status" :class="{ valid: isFormValid }">
84 <span v-if="isFormValid">Form ready for transmission</span>
85 <span v-else>Fill in all required fields</span>
86 </div>
87 </div>
88
89 <div class="data-preview">
90 <h3>Transmitted Data Preview</h3>
91 <div class="preview-box">
92 <pre>{{ {
93 astronaut: astronautName || null,
94 missionCode: missionCode || null,
95 description: missionDescription || null,
96 status: statusReport || null,
97 valid: isFormValid
98} }}</pre>
99 </div>
100 </div>
101
102 <div class="vmodel-info">
103 <h3>How v-model works:</h3>
104 <div class="info-content">
105 <p><code>v-model</code> is shorthand for:</p>
106 <code class="code-example">:value="variable" @input="variable = $event.target.value"</code>
107 <p>Automatically synchronizes data between input and variable!</p>
108 </div>
109 </div>
110 </div>
111</template>
112
113<style scoped>
114.data-transmission {
115 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
116 color: #00ff88;
117 padding: 30px;
118 border-radius: 15px;
119 border: 2px solid #00b4d8;
120 font-family: 'Courier New', monospace;
121 max-width: 900px;
122 margin: 0 auto;
123}
124
125.transmission-header {
126 text-align: center;
127 margin-bottom: 30px;
128 padding-bottom: 20px;
129 border-bottom: 2px solid #00b4d8;
130}
131
132.transmission-header h2 {
133 color: #00ff88;
134 margin: 0 0 10px 0;
135 font-size: 28px;
136 text-shadow: 0 0 10px #00ff88;
137}
138
139.subtitle {
140 color: #00b4d8;
141 margin: 0;
142 font-size: 14px;
143}
144
145.form-container {
146 margin-bottom: 30px;
147}
148
149.form-group {
150 margin-bottom: 25px;
151}
152
153.form-group label {
154 display: block;
155 color: #00b4d8;
156 margin-bottom: 10px;
157 font-size: 14px;
158 font-weight: bold;
159}
160
161.data-input,
162.data-textarea {
163 width: 100%;
164 background: rgba(0, 180, 216, 0.1);
165 border: 2px solid #00b4d8;
166 border-radius: 8px;
167 padding: 12px 15px;
168 color: #00ff88;
169 font-size: 16px;
170 font-family: 'Courier New', monospace;
171 box-sizing: border-box;
172 transition: all 0.3s ease;
173}
174
175.data-input:focus,
176.data-textarea:focus {
177 outline: none;
178 border-color: #00ff88;
179 box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
180 background: rgba(0, 180, 216, 0.15);
181}
182
183.data-textarea {
184 resize: vertical;
185}
186
187.input-feedback {
188 margin-top: 8px;
189 color: #00b4d8;
190 font-size: 12px;
191 padding: 8px 12px;
192 background: rgba(0, 180, 216, 0.05);
193 border-radius: 4px;
194}
195
196.input-feedback strong {
197 color: #00ff88;
198}
199
200.textarea-stats {
201 margin-top: 8px;
202 display: flex;
203 gap: 20px;
204 color: #00b4d8;
205 font-size: 12px;
206}
207
208.validation-status {
209 padding: 15px;
210 background: rgba(255, 107, 53, 0.1);
211 border: 2px solid #ff6b35;
212 border-radius: 8px;
213 text-align: center;
214 font-weight: bold;
215 color: #ff6b35;
216 transition: all 0.3s ease;
217}
218
219.validation-status.valid {
220 background: rgba(0, 255, 136, 0.1);
221 border-color: #00ff88;
222 color: #00ff88;
223}
224
225.data-preview {
226 margin-bottom: 30px;
227}
228
229.data-preview h3 {
230 color: #00ff88;
231 margin-bottom: 15px;
232}
233
234.preview-box {
235 background: rgba(0, 0, 0, 0.4);
236 border: 1px solid #00b4d8;
237 border-radius: 8px;
238 padding: 20px;
239 overflow-x: auto;
240}
241
242.preview-box pre {
243 margin: 0;
244 color: #00ff88;
245 font-size: 14px;
246 line-height: 1.6;
247}
248
249.vmodel-info {
250 background: rgba(0, 255, 136, 0.05);
251 padding: 20px;
252 border-radius: 8px;
253 border: 1px solid #00ff88;
254}
255
256.vmodel-info h3 {
257 color: #00ff88;
258 margin-top: 0;
259}
260
261.info-content {
262 color: #00b4d8;
263}
264
265.info-content p {
266 margin: 10px 0;
267}
268
269.info-content code {
270 background: rgba(0, 255, 136, 0.2);
271 padding: 2px 6px;
272 border-radius: 4px;
273 color: #00ff88;
274 font-size: 13px;
275}
276
277.code-example {
278 display: block;
279 background: rgba(0, 180, 216, 0.2);
280 padding: 10px 15px;
281 border-radius: 6px;
282 margin: 10px 0;
283 font-size: 12px;
284}
285</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 v-model in Vue.js?
2. v-model is a shorthand for which combination?
These are 2 of 7 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Use v-model with a text input and display the entered value in a paragraph.
- Horizontal ordering
Arrange the correct syntax for an input with v-model:
- Click in order
Arrange the syntax for a checkbox with v-model:
- Code editor
Use checkbox (array) to select equipment and radio to choose a mission type.
- Vertical ordering
Arrange the steps of creating a reactive Vue form from data to validation:
- Horizontal ordering
Arrange the correct syntax for a select option with v-for:
- Code editor
Build a select with v-for and :value for a list of objects.