List Rendering

v-for repeats an element over an array, an object or a range, and :key tells Vue which DOM node belongs to which item.

Syntaxv-for="(item, index) in items" :key="item.id"

v-for renders its element once per item. The value can be an array, an object, a number, a string or any iterable.

The forms

  • v-for="item in items", or item of items, which is the same thing written closer to JavaScript.
  • v-for="(item, index) in items" for the position, counting from zero.
  • Over an object, the aliases are (value, key, index) in that order, so the value comes first.
  • v-for="n in 10" gives a range, and it starts at 1, not 0.
  • You can destructure the item: v-for="{ id, title } in tasks", and with a position, v-for="({ id }, index) in tasks".

Loops nest, and an inner loop can read the outer loop's variables. <template v-for> repeats a group of elements without adding a wrapper; the :key goes on the <template>, not on its children.

Why :key matters

Without a key, Vue patches elements in place by position. If the list reorders, the DOM node at index 2 is reused for whatever is now at index 2, so state inside it, such as a half-typed input or a focused element, stays behind. Give :key a stable primitive from the data, usually an id. Mutating methods like push and splice update the view, and so does assigning a new array.

Example

Example · vue
<!-- TaskList.vue -->
<script setup lang="ts">
interface Task {
  id: number
  title: string
  done: boolean
}

const tasks: Task[] = [
  { id: 101, title: 'Install the dependencies', done: true },
  { id: 102, title: 'Write the first component', done: false },
  { id: 103, title: 'Read the template guide', done: false }
]
</script>

<template>
  <ol class="tasks">
    <!-- destructured item, position second, key from the data -->
    <li v-for="({ id, title, done }, index) in tasks" :key="id">
      <span class="num">{{ index + 1 }}.</span>
      <!-- { done } is shorthand for { done: done } -->
      <span :class="{ done }">{{ title }}</span>
    </li>
  </ol>
</template>

<style scoped>
.tasks {
  list-style: none;
  padding: 0;
}

.num {
  color: #64748b;
  margin-right: 0.35rem;
}

.done {
  text-decoration: line-through;
  color: #64748b;
}
</style>

When to use it

  • Render rows of records returned from an API into a table.
  • List the keys and values of a config or headers object for debugging.
  • Draw a fixed number of skeleton placeholders while data loads.
  • Repeat a dt and dd pair per entry without an invalid wrapper element.

More examples

Over an object, and over a range

Object iteration is how you render a map you did not write, such as response headers or a config dump; the argument order catches people out, because the first alias is the value and the key is second. Iteration follows Object.keys order. The range form is the one to use for fixed repeats like rating stars or skeleton rows, and because it begins at 1 you subtract rather than add when you need a zero-based position.

Example · vue
<!-- RequestDebug.vue -->
<script setup lang="ts">
const headers: Record<string, string> = {
  'content-type': 'application/json',
  'cache-control': 'no-store'
}
</script>

<template>
  <!-- object aliases are (value, key, index): the value comes first -->
  <table class="headers">
    <thead>
      <tr><th>#</th><th>Header</th><th>Value</th></tr>
    </thead>
    <tbody>
      <tr v-for="(value, name, index) in headers" :key="name">
        <td>{{ index }}</td>
        <td>{{ name }}</td>
        <td>{{ value }}</td>
      </tr>
    </tbody>
  </table>

  <!-- a range starts at 1, so this renders stars 1 to 5 -->
  <p class="stars">
    <span v-for="n in 5" :key="n">{{ n }}</span>
  </p>
</template>

<style scoped>
.headers { border-collapse: collapse; }
.headers th, .headers td { border: 1px solid #e2e8f0; padding: 0.25rem 0.5rem; }
</style>

template v-for, with the key on the wrapper

Each iteration here produces two sibling elements, and a wrapper div would be invalid inside a dl. template v-for repeats the group and adds nothing to the DOM. The one rule to remember is that :key belongs on the template, not on the dt or the dd; a key on the children is a mistake Vue cannot use. The same pattern covers tr groups in a table body and repeated grid children.

Example · vue
<!-- Glossary.vue -->
<script setup lang="ts">
interface Entry {
  term: string
  definition: string
}

const entries: Entry[] = [
  { term: 'directive', definition: 'An attribute starting with v- that Vue compiles.' },
  { term: 'ref', definition: 'A box holding one value that Vue tracks.' }
]
</script>

<template>
  <dl class="glossary">
    <template v-for="entry in entries" :key="entry.term">
      <dt>{{ entry.term }}</dt>
      <dd>{{ entry.definition }}</dd>
    </template>
  </dl>
</template>

<style scoped>
.glossary dt { font-weight: 600; }
.glossary dd { margin: 0 0 0.5rem; }
</style>

Derive the list in script, not in the template

Filtering and sorting belong beside your data, not inside v-for, so they can be tested and so the template stays readable. The copy with the spread is the part people skip: Array.prototype.sort and reverse mutate the array they are called on, so sorting the source array in a render path changes your data as a side effect. Import the function and loop over its result. A later stage replaces the call with a cached computed property.

Example · ts
// src/lib/tasks.ts
export interface Task {
  id: number
  title: string
  done: boolean
}

export function openTasksNewestFirst(tasks: Task[]): Task[] {
  // copy before sorting: sort and reverse mutate the array in place
  return [...tasks].filter((task) => !task.done).sort((a, b) => b.id - a.id)
}

const all: Task[] = [
  { id: 1, title: 'One', done: false },
  { id: 2, title: 'Two', done: true },
  { id: 3, title: 'Three', done: false }
]

console.log(openTasksNewestFirst(all).map((task) => task.id))
// Output: [ 3, 1 ]

Discussion

  • Be the first to comment on this lesson.