Vue.js course · Module 2: Template Syntax

List Rendering with v-for

6 min read
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. 1. How do you iterate over an array in Vue?

  2. 2. Why is :key important in v-for?

  3. 3. How do you get the element index in v-for?

  4. 4. How do you read both the value and the key of each object property in v-for?

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

Useful articles