Vue.js course · Module 2: Template Syntax
List Rendering with v-for
In this lesson5
Just as the NOVA base displays data from multiple sensors simultaneously, v-for renders a list of elements.
The station has dozens of sensors, and their number keeps changing: technicians install new ones and remove broken ones. Copying an <li> tag by hand for every sensor is out of the question, because the template would need fixing after every hardware change. The v-for directive repeats an element for each item in the data, and when the data changes, the list in the hologram changes with it.
Basic v-for
The syntax resembles a JavaScript loop: in v-for="sensor in sensors" the word sensor is an alias for the current item, and sensors is the source array. Writing (sensor, index) also gives you the item's position, counted from zero. We will discuss the :key attribute in a moment - for now it is enough that it is a unique identifier:
1<template>
2 <div class="sensors-list" style="background: #0a0e27; padding: 1rem;">
3 <!-- Iterating over an array -->
4 <ul style="list-style: none; color: #00ff88;">
5 <li v-for="sensor in sensors" :key="sensor.id"
6 style="border-left: 2px solid #00b4d8; padding: 0.5rem; margin: 0.5rem 0;">
7 {{ sensor.name }} - {{ sensor.value }}{{ sensor.unit }}
8 </li>
9 </ul>
10
11 <!-- With index -->
12 <div v-for="(sensor, index) in sensors" :key="sensor.id"
13 style="color: #00b4d8;">
14 [{{ index + 1 }}] {{ sensor.name }}: {{ sensor.value }}
15 </div>
16 </div>
17</template>The screen shows three li elements and three div elements numbered from 1, because we add one to the index for human readers. The sensor alias exists only inside the element with v-for. Instead of in you can also write of, which is closer to JavaScript's iterator syntax.
The data is an array of objects in a ref:
1<script setup>
2import { ref } from 'vue'
3
4const sensors = ref([
5 { id: 1, name: 'Oxygen Level', value: 21.5, unit: '%' },
6 { id: 2, name: 'Temperature', value: 22, unit: '°C' },
7 { id: 3, name: 'Pressure', value: 101.3, unit: 'kPa' }
8])
9</script>Vue detects the methods that change an array, such as push(), splice() or sort(), so a new sensor added with push appears on the list right away. Methods like filter() or slice() return a new array and leave the old one alone - you have to assign the result to sensors.value.
Iterating Over Objects
v-for can also walk through an object's properties. The aliases are the value, the key and the index, in that order, and the order of properties follows the result of Object.values():
1<template>
2 <div class="module-specs" style="background: #0a0e27; padding: 1rem;">
3 <!-- Values -->
4 <div v-for="value in moduleDetails" :key="value" style="color: #00ff88;">
5 {{ value }}
6 </div>
7
8 <!-- Key and value -->
9 <div v-for="(value, key) in moduleDetails" :key="key"
10 style="color: #00b4d8; border-bottom: 1px solid #00ff8833; padding: 0.25rem;">
11 <strong>{{ key }}:</strong> {{ value }}
12 </div>
13
14 <!-- Key, value, and index -->
15 <div v-for="(value, key, index) in moduleDetails" :key="key">
16 {{ index }}. {{ key }}: {{ value }}
17 </div>
18 </div>
19</template>The last loop prints, for example, 0. name: Reactor Core Alpha. In the first one the value serves as the key only because all the values happen to be different here - the property name is the safer choice. The object itself is an ordinary reactive object:
1<script setup>
2import { reactive } from 'vue'
3
4const moduleDetails = reactive({
5 name: 'Reactor Core Alpha',
6 type: 'Plasma Fusion',
7 output: '500 MW',
8 location: 'Central Hub, Mars Base'
9})
10</script>Why is :key important?
Without a key, Vue updates a list with an in-place strategy: when the order of the data changes, it does not move the elements, it only patches the content of each one at its position. For plain text that is enough, but a form field or a component with state will then stay at the wrong position. A unique :key gives every element an identity. Compare three variants, each showing only the opening tag:
1<template>
2 <!-- GOOD - unique ID -->
3 <div v-for="item in items" :key="item.id">
4
5 <!-- BAD - index as key (problems with sorting/filtering) -->
6 <div v-for="(item, index) in items" :key="index">
7
8 <!-- BAD - no key -->
9 <div v-for="item in items">
10</template>The index as a key behaves like no key at all, because after sorting the same number belongs to a different sensor. A missing key causes no error or console warning in Vue - it is caught only by a linter (the vue/require-v-for-key rule from eslint-plugin-vue). With duplicate keys, Vue warns in development mode while updating the list. A key should be a string or a number, ideally an identifier from the data.
v-for with v-if
When you want to show only the active sensors, it is tempting to add v-if to the element with v-for. In Vue 3, however, v-if has the higher priority, so it cannot see the sensor variable and rendering ends with an error. There are two correct ways out:
1<template>
2 <div class="active-sensors">
3 <!-- GOOD - template wrapper -->
4 <template v-for="sensor in sensors" :key="sensor.id">
5 <div v-if="sensor.isActive" style="color: #00ff88;">
6 {{ sensor.name }}
7 </div>
8 </template>
9
10 <!-- OR - computed filter -->
11 <div v-for="sensor in activeSensors" :key="sensor.id"
12 style="color: #00ff88; border-left: 2px solid #00ff88; padding: 0.5rem;">
13 {{ sensor.name }}
14 </div>
15 </div>
16</template>Both versions show the same names. With <template v-for> the key sits on the <template> tag, not on the child - otherwise the compiler reports an error. The second version uses a computed property:
1<script setup>
2import { ref, computed } from 'vue'
3
4const sensors = ref([
5 { id: 1, name: 'Oxygen Level', isActive: true },
6 { id: 2, name: 'Dust Filter', isActive: false },
7 { id: 3, name: 'Pressure', isActive: true }
8])
9
10const activeSensors = computed(() =>
11 sensors.value.filter(s => s.isActive)
12)
13</script>The original array does not change - the computed property returns a new, filtered one. The Vue docs recommend exactly this approach, and it is my choice too: the template stays simple and the filter has a name.
Number Range
v-for also accepts an integer and repeats the element that many times. Note that n starts at 1, not at 0:
1<template>
2 <div class="power-bars">
3 <!-- Render 10 times -->
4 <span v-for="n in 10" :key="n"
5 style="display: inline-block; width: 20px; height: 20px; background: #00ff88; margin: 2px;">
6 </span>
7
8 <!-- Result: 10 green squares -->
9 </div>
10</template>Ten squares make a power gauge, and with :class you could light up only those whose n does not exceed the energy level. In the next lesson you will reach an element of such a list directly through template refs.
Remember: v-for is the sensor assembly line, and :key is the serial number by which Vue recognizes every unit.
Code for this lesson: App.vue
1<script setup>
2import { ref } from 'vue'
3
4const crewMembers = ref([
5 { id: 1, name: 'Dr. Elena Rodriguez', role: 'Commander', specialization: 'Astrobiology', status: 'active' },
6 { id: 2, name: 'James Chen', role: 'Engineer', specialization: 'Mechanical Systems', status: 'active' },
7 { id: 3, name: 'Sarah Johnson', role: 'Scientist', specialization: 'Geology', status: 'on-mission' },
8 { id: 4, name: 'Marcus Williams', role: 'Pilot', specialization: 'Navigation', status: 'active' },
9 { id: 5, name: 'Dr. Yuki Tanaka', role: 'Medical Officer', specialization: 'Space Medicine', status: 'active' }
10])
11
12const missionLogs = ref([
13 { id: 101, sol: 42, type: 'discovery', message: 'Ice core samples found' },
14 { id: 102, sol: 43, type: 'maintenance', message: 'Solar panel maintenance completed' },
15 { id: 103, sol: 44, type: 'research', message: 'Rock mineral composition analysis' },
16 { id: 104, sol: 45, type: 'communication', message: 'Contact with Mission Control Center' }
17])
18
19const equipment = ref([
20 { id: 'eq1', name: 'Rover Explorer-1', status: 'operational', battery: 85 },
21 { id: 'eq2', name: 'Drilling System', status: 'operational', battery: 92 },
22 { id: 'eq3', name: 'Weather Station', status: 'maintenance', battery: 45 },
23 { id: 'eq4', name: 'Communication Array', status: 'operational', battery: 100 }
24])
25
26const modules = ref([
27 {
28 id: 'm1',
29 name: 'Habitat Module',
30 sections: ['Living Quarters', 'Laboratory', 'Greenhouse']
31 },
32 {
33 id: 'm2',
34 name: 'Research Module',
35 sections: ['Chemistry Lab', 'Biology Lab', 'Geology Lab']
36 },
37 {
38 id: 'm3',
39 name: 'Storage Module',
40 sections: ['Food Storage', 'Equipment Storage', 'Water Tanks']
41 }
42])
43
44function addCrewMember() {
45 const newId = Math.max(...crewMembers.value.map(m => m.id)) + 1
46 crewMembers.value.push({
47 id: newId,
48 name: 'New Recruit',
49 role: 'Trainee',
50 specialization: 'General',
51 status: 'training'
52 })
53}
54
55function removeCrewMember(id) {
56 crewMembers.value = crewMembers.value.filter(member => member.id !== id)
57}
58
59function addLog() {
60 const newId = Math.max(...missionLogs.value.map(l => l.id)) + 1
61 const currentSol = Math.max(...missionLogs.value.map(l => l.sol)) + 1
62 missionLogs.value.unshift({
63 id: newId,
64 sol: currentSol,
65 type: 'info',
66 message: 'New entry in the mission log'
67 })
68}
69</script>
70
71<template>
72 <div class="mission-base">
73 <h1>NOVA Base - Resource List</h1>
74
75 <!-- v-for with array of objects + :key -->
76 <section class="crew-section">
77 <div class="section-header">
78 <h2>Crew ({{ crewMembers.length }} members)</h2>
79 <button @click="addCrewMember" class="btn btn-add">+ Add member</button>
80 </div>
81
82 <div class="crew-grid">
83 <div
84 v-for="member in crewMembers"
85 :key="member.id"
86 class="crew-card"
87 :class="'status-' + member.status"
88 >
89 <div class="crew-header">
90 <h3>{{ member.name }}</h3>
91 <span class="role-badge">{{ member.role }}</span>
92 </div>
93 <p class="specialization">{{ member.specialization }}</p>
94 <div class="crew-footer">
95 <span class="status-badge" :class="member.status">
96 {{ member.status }}
97 </span>
98 <button @click="removeCrewMember(member.id)" class="btn-remove">×</button>
99 </div>
100 </div>
101 </div>
102 </section>
103
104 <!-- v-for with index -->
105 <section class="logs-section">
106 <div class="section-header">
107 <h2>Mission Log</h2>
108 <button @click="addLog" class="btn btn-add">+ New entry</button>
109 </div>
110
111 <div class="logs-list">
112 <div
113 v-for="(log, index) in missionLogs"
114 :key="log.id"
115 class="log-entry"
116 :class="'log-' + log.type"
117 >
118 <span class="log-number">#{{ index + 1 }}</span>
119 <span class="log-sol">Sol {{ log.sol }}</span>
120 <span class="log-type">{{ log.type }}</span>
121 <span class="log-message">{{ log.message }}</span>
122 </div>
123 </div>
124 </section>
125
126 <!-- v-for with nested loops -->
127 <section class="modules-section">
128 <h2>Base Modules</h2>
129 <div class="modules-grid">
130 <div
131 v-for="module in modules"
132 :key="module.id"
133 class="module-card"
134 >
135 <h3>{{ module.name }}</h3>
136 <ul class="sections-list">
137 <li
138 v-for="(section, sectionIndex) in module.sections"
139 :key="module.id + '-' + sectionIndex"
140 class="section-item"
141 >
142 <span class="section-number">{{ sectionIndex + 1 }}.</span>
143 {{ section }}
144 </li>
145 </ul>
146 </div>
147 </div>
148 </section>
149
150 <!-- v-for over equipment with conditional styling -->
151 <section class="equipment-section">
152 <h2>Equipment</h2>
153 <div class="equipment-list">
154 <div
155 v-for="item in equipment"
156 :key="item.id"
157 class="equipment-card"
158 >
159 <div class="equipment-name">{{ item.name }}</div>
160 <div class="equipment-status" :class="item.status">
161 {{ item.status }}
162 </div>
163 <div class="battery-container">
164 <div class="battery-label">Battery:</div>
165 <div class="battery-bar">
166 <div
167 class="battery-fill"
168 :style="{ width: item.battery + '%' }"
169 :class="{
170 'battery-low': item.battery < 50,
171 'battery-medium': item.battery >= 50 && item.battery < 80,
172 'battery-high': item.battery >= 80
173 }"
174 >
175 {{ item.battery }}%
176 </div>
177 </div>
178 </div>
179 </div>
180 </div>
181 </section>
182
183 <div class="info-box">
184 <h3>v-for - Rendering Lists</h3>
185 <ul>
186 <li><strong>v-for="item in items"</strong> - iterate over an array</li>
187 <li><strong>v-for="(item, index) in items"</strong> - with index</li>
188 <li><strong>:key="item.id"</strong> - ALWAYS use a unique key</li>
189 <li><strong>Nested loops</strong> - you can nest v-for</li>
190 <li><strong>v-for with :class/:style</strong> - dynamic styling of list elements</li>
191 </ul>
192 </div>
193 </div>
194</template>
195
196<style scoped>
197.mission-base {
198 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
199 min-height: 100vh;
200 padding: 2rem;
201 color: #ffffff;
202 font-family: 'Courier New', monospace;
203}
204
205h1 {
206 text-align: center;
207 color: #00ff88;
208 text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
209 margin-bottom: 3rem;
210}
211
212h2 {
213 color: #00b4d8;
214 margin-bottom: 1.5rem;
215}
216
217section {
218 background: rgba(0, 255, 136, 0.05);
219 border: 2px solid #00ff88;
220 border-radius: 10px;
221 padding: 1.5rem;
222 margin-bottom: 2rem;
223}
224
225.section-header {
226 display: flex;
227 justify-content: space-between;
228 align-items: center;
229 margin-bottom: 1.5rem;
230 flex-wrap: wrap;
231 gap: 1rem;
232}
233
234.btn-add {
235 background: #00ff88;
236 color: #0a0e27;
237 border: none;
238 padding: 0.75rem 1.5rem;
239 border-radius: 5px;
240 font-weight: bold;
241 cursor: pointer;
242 font-family: 'Courier New', monospace;
243 transition: all 0.3s ease;
244}
245
246.btn-add:hover {
247 transform: scale(1.05);
248 box-shadow: 0 5px 15px rgba(0, 255, 136, 0.4);
249}
250
251.crew-grid {
252 display: grid;
253 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
254 gap: 1.5rem;
255}
256
257.crew-card {
258 background: rgba(0, 180, 216, 0.1);
259 border: 2px solid #00b4d8;
260 border-radius: 8px;
261 padding: 1.5rem;
262 transition: all 0.3s ease;
263}
264
265.crew-card:hover {
266 transform: translateY(-5px);
267 box-shadow: 0 10px 30px rgba(0, 180, 216, 0.3);
268}
269
270.crew-card.status-on-mission {
271 border-color: #ffb400;
272 background: rgba(255, 180, 0, 0.1);
273}
274
275.crew-header {
276 display: flex;
277 justify-content: space-between;
278 align-items: start;
279 margin-bottom: 0.5rem;
280}
281
282.crew-header h3 {
283 color: #00ff88;
284 margin: 0;
285 font-size: 1.1rem;
286}
287
288.role-badge {
289 background: #00b4d8;
290 color: #ffffff;
291 padding: 0.25rem 0.75rem;
292 border-radius: 12px;
293 font-size: 0.8rem;
294 font-weight: bold;
295}
296
297.specialization {
298 color: #ffffff;
299 margin: 0.5rem 0;
300 font-size: 0.9rem;
301}
302
303.crew-footer {
304 display: flex;
305 justify-content: space-between;
306 align-items: center;
307 margin-top: 1rem;
308}
309
310.status-badge {
311 padding: 0.25rem 0.75rem;
312 border-radius: 12px;
313 font-size: 0.8rem;
314 font-weight: bold;
315 text-transform: uppercase;
316}
317
318.status-badge.active {
319 background: rgba(0, 255, 136, 0.3);
320 color: #00ff88;
321}
322
323.status-badge.on-mission {
324 background: rgba(255, 180, 0, 0.3);
325 color: #ffb400;
326}
327
328.status-badge.training {
329 background: rgba(0, 180, 216, 0.3);
330 color: #00b4d8;
331}
332
333.btn-remove {
334 background: #ff0055;
335 color: #ffffff;
336 border: none;
337 width: 30px;
338 height: 30px;
339 border-radius: 50%;
340 font-size: 1.5rem;
341 cursor: pointer;
342 display: flex;
343 align-items: center;
344 justify-content: center;
345 transition: all 0.3s ease;
346}
347
348.btn-remove:hover {
349 transform: scale(1.1);
350 box-shadow: 0 0 10px rgba(255, 0, 85, 0.5);
351}
352
353.logs-list {
354 display: flex;
355 flex-direction: column;
356 gap: 0.75rem;
357}
358
359.log-entry {
360 display: grid;
361 grid-template-columns: auto auto auto 1fr;
362 gap: 1rem;
363 padding: 1rem;
364 background: rgba(0, 180, 216, 0.1);
365 border-left: 4px solid #00b4d8;
366 border-radius: 5px;
367 align-items: center;
368 transition: all 0.3s ease;
369}
370
371.log-entry:hover {
372 background: rgba(0, 180, 216, 0.2);
373 transform: translateX(5px);
374}
375
376.log-entry.log-discovery {
377 border-left-color: #00ff88;
378}
379
380.log-entry.log-maintenance {
381 border-left-color: #ffb400;
382}
383
384.log-number {
385 color: #00b4d8;
386 font-weight: bold;
387 font-size: 0.9rem;
388}
389
390.log-sol {
391 color: #00ff88;
392 font-weight: bold;
393}
394
395.log-type {
396 background: rgba(0, 255, 136, 0.2);
397 color: #00ff88;
398 padding: 0.25rem 0.75rem;
399 border-radius: 12px;
400 font-size: 0.8rem;
401 text-transform: uppercase;
402}
403
404.log-message {
405 color: #ffffff;
406}
407
408.modules-grid {
409 display: grid;
410 grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
411 gap: 1.5rem;
412}
413
414.module-card {
415 background: rgba(0, 255, 136, 0.1);
416 border: 2px solid #00ff88;
417 border-radius: 8px;
418 padding: 1.5rem;
419}
420
421.module-card h3 {
422 color: #00ff88;
423 margin-top: 0;
424 margin-bottom: 1rem;
425}
426
427.sections-list {
428 list-style: none;
429 padding: 0;
430 margin: 0;
431}
432
433.section-item {
434 padding: 0.75rem;
435 background: rgba(0, 180, 216, 0.1);
436 border-radius: 5px;
437 margin-bottom: 0.5rem;
438 color: #ffffff;
439 transition: all 0.3s ease;
440}
441
442.section-item:hover {
443 background: rgba(0, 180, 216, 0.2);
444 transform: translateX(5px);
445}
446
447.section-number {
448 color: #00b4d8;
449 font-weight: bold;
450 margin-right: 0.5rem;
451}
452
453.equipment-list {
454 display: grid;
455 grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
456 gap: 1.5rem;
457}
458
459.equipment-card {
460 background: rgba(0, 180, 216, 0.1);
461 border: 2px solid #00b4d8;
462 border-radius: 8px;
463 padding: 1.5rem;
464}
465
466.equipment-name {
467 color: #00ff88;
468 font-weight: bold;
469 font-size: 1.1rem;
470 margin-bottom: 0.5rem;
471}
472
473.equipment-status {
474 padding: 0.25rem 0.75rem;
475 border-radius: 12px;
476 font-size: 0.8rem;
477 font-weight: bold;
478 display: inline-block;
479 margin-bottom: 1rem;
480 text-transform: uppercase;
481}
482
483.equipment-status.operational {
484 background: rgba(0, 255, 136, 0.3);
485 color: #00ff88;
486}
487
488.equipment-status.maintenance {
489 background: rgba(255, 180, 0, 0.3);
490 color: #ffb400;
491}
492
493.battery-container {
494 margin-top: 1rem;
495}
496
497.battery-label {
498 color: #ffffff;
499 font-size: 0.9rem;
500 margin-bottom: 0.5rem;
501}
502
503.battery-bar {
504 background: rgba(255, 255, 255, 0.1);
505 border-radius: 10px;
506 height: 30px;
507 overflow: hidden;
508}
509
510.battery-fill {
511 height: 100%;
512 display: flex;
513 align-items: center;
514 justify-content: center;
515 font-weight: bold;
516 font-size: 0.9rem;
517 transition: all 0.3s ease;
518}
519
520.battery-low {
521 background: linear-gradient(90deg, #ff0055, #ff6600);
522}
523
524.battery-medium {
525 background: linear-gradient(90deg, #ffb400, #ffd700);
526 color: #0a0e27;
527}
528
529.battery-high {
530 background: linear-gradient(90deg, #00ff88, #00b4d8);
531 color: #0a0e27;
532}
533
534.info-box {
535 background: rgba(0, 255, 136, 0.05);
536 border: 2px solid #00ff88;
537 border-radius: 10px;
538 padding: 1.5rem;
539 margin-top: 2rem;
540}
541
542.info-box h3 {
543 color: #00ff88;
544 margin-top: 0;
545}
546
547.info-box ul {
548 list-style: none;
549 padding: 0;
550}
551
552.info-box li {
553 padding: 0.5rem 0;
554 color: #ffffff;
555 border-left: 3px solid #00b4d8;
556 padding-left: 1rem;
557 margin: 0.5rem 0;
558}
559
560.info-box strong {
561 color: #00b4d8;
562}
563</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 iterate over an array in Vue?
2. Why is :key important in v-for?
3. How do you get the element index in v-for?
4. How do you read both the value and the key of each object property in v-for?
5. Which practice is recommended when you want to filter list elements?
Hands-on tasks in the game
- Code editor
Use v-for to render a list of sensors with :key and index.
- Click in order
Arrange the v-for syntax with :key (v-for first, then :key):
- Vertical ordering
Order the v-for arguments for objects from value to index:
- Horizontal ordering
Arrange the correct v-for syntax with :key:
- Code editor
Use v-for to iterate over object properties (value, key, index).
- Vertical ordering
Arrange from best to worst way of setting :key:
- Click in order
Arrange the correct usage of a template wrapper with v-for and v-if: