Vue.js 9 🟢 List Rendering with v-for and the Structural Importance of the key Attribute
Arrays are the backbone of most data-driven interfaces. A list of users, a set of products, a collection of comments—rendering these collections is one of the most common tasks in any application. Vue’s v-for directive exists to make this task declarative and reactive, but it comes with a subtlety that trips up even experienced developers: the key attribute. Without a proper understanding of key, you can write code that appears to work correctly until the list changes order, items are inserted in the middle, or state becomes mysteriously mismatched with the data it represents.
This chapter covers list rendering from the ground up. You will learn the syntax of v-for, how to iterate over arrays and objects, and how to access the index or property name during iteration. You will then dive deep into the key attribute—why it exists, what happens without it, and the specific pitfalls that arise when you use the array index as a key. The goal is not just to teach you the syntax but to give you a mental model of Vue’s virtual DOM diffing algorithm that makes the right choices obvious.
By the end, you will understand why the Vue style guide and ESLint plugin both insist on providing a unique key for every v-for iteration, and you will know how to choose a key that keeps your lists stable, efficient, and free of state mismatches.
Key point: The key attribute tells Vue how to identify each item in a list across re-renders. Without it, Vue patches elements in place by position rather than identity, which means the DOM can become out of sync with your data when the list order changes. Always provide a unique, stable key.
Why v-for and key exist
The repetition problem. Rendering a list of items manually means duplicating markup for every entry, which is tedious, error-prone, and impossible to keep in sync with changing data. v-for exists to eliminate this duplication. You describe the structure of a single item, and Vue repeats it for each element in your collection . This is the same principle as Array.prototype.map in JavaScript, but expressed declaratively in the template.
The identity problem. When Vue re-renders a list, it does not simply throw away the old DOM and build a new one. It compares the new virtual DOM against the old and makes the minimum number of changes. To do this efficiently, Vue needs to know which old elements correspond to which new elements. Without a key, Vue uses a “patch in place” strategy: it reuses the existing DOM nodes and updates their content based on their position . This is fast, but it only works correctly when the list content does not depend on component state or temporary DOM state like form input values .
The state mismatch problem. Consider a list where each item contains an <input> with a value bound to the item’s data. If you reorder the list without keys, Vue updates the text content of each <li> in place, but the <input> elements are reused by position. The result is that the input values stay in their original positions while the text labels move, creating a visual mismatch between what the user sees and what the data actually represents . This is the concrete manifestation of the identity problem.
The performance problem. Without keys, Vue cannot distinguish between “this item moved” and “this item was replaced.” It assumes the least amount of movement and patches content in place. When you add keys, Vue can track each node’s identity and perform intelligent reordering: moving existing DOM nodes to match the new order, removing nodes whose keys no longer exist, and creating nodes only for genuinely new items . For dynamic lists, keyed updates are both more correct and more efficient.
The Vue 3 refinement. Vue 3 introduced automatic key generation for v-if/v-else branches and changed the rules for keys on <template v-for>. The key attribute is now placed on the <template> tag itself rather than on individual children . These changes reflect the continued importance of key management in modern Vue applications.
a. Basic v-for syntax and array iteration
The v-for directive uses a special syntax: item in items, where items is the source data and item is an alias for the current element.
<template>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.message }}
</li>
</ul>
</template>
<script setup>
const items = [
{ id: 1, message: 'Foo' },
{ id: 2, message: 'Bar' }
]
</script>
The v-for directive attaches to the element that should be repeated. For an unordered list, you add v-for to the <li>, not the <ul> . The <ul> is the container; the <li> is what repeats.
You can also use of as the delimiter instead of in, which more closely mirrors JavaScript’s iterator syntax:
<div v-for="item of items"></div>
Both forms are equivalent. The choice is stylistic.
b. Accessing index and iterating objects
v-for provides an optional second alias for the current item’s index in the array.
<li v-for="(item, index) in items">
{{ index }} - {{ item.message }}
</li>
The scoping works like a forEach callback: item and index are only available within the v-for element, but expressions can still access properties from the parent scope .
You can also iterate over an object’s properties. The iteration order follows Object.keys().
<li v-for="(value, key) in myObject">
{{ key }}: {{ value }}
</li>
For objects, the aliases are (value, key, index). The key here is the property name, not to be confused with the special key attribute .
Destructuring works with v-for aliases, just like function parameters:
<li v-for="{ message } in items">
{{ message }}
</li>
This is particularly useful when items are objects with many properties and you only need a few.
c. The key attribute and list state
The key attribute is where list rendering becomes subtle. Vue’s official documentation is unambiguous: the key special attribute “is primarily used as a hint for Vue’s virtual DOM algorithm to identify vnodes when diffing the new list of nodes against the old list” .
Without keys, Vue uses an in-place patch strategy. When the data order changes, Vue patches each existing element to reflect what should be rendered at that index . This is efficient but wrong when the list output depends on state.
Consider this example:
<template>
<ul>
<li v-for="item in items" :key="item.id">
<input v-model="item.name">
{{ item.name }}
</li>
</ul>
</template>
With :key="item.id", each <li> is tied to a specific item’s identity. When the list reorders, Vue moves the DOM nodes, and the input values stay with their corresponding items.
Without the key, or with :key="index", the inputs stay in their positional slots while the text content updates. The user sees input values that no longer match the labels next to them .
The Vue style guide recommends providing a key with v-for whenever possible, unless the iterated DOM content is simple and contains no components or stateful elements .
Complete Example Session
<!-- ============================================ -->
<!-- PART 1: BASIC v-for WITH KEY -->
<!-- ============================================ -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
</template>
<script setup>
const items = [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' }
]
</script>
<!-- ============================================ -->
<!-- PART 2: v-for WITH INDEX -->
<!-- ============================================ -->
<template>
<ul>
<li v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</li>
</ul>
</template>
<script setup>
const items = [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' }
]
</script>
<!-- ============================================ -->
<!-- PART 3: v-for OVER OBJECT -->
<!-- ============================================ -->
<template>
<ul>
<li v-for="(value, key) in user" :key="key">
{{ key }}: {{ value }}
</li>
</ul>
</template>
<script setup>
const user = {
name: 'Alice',
email: 'alice@example.com',
role: 'admin'
}
</script>
<!-- ============================================ -->
<!-- PART 4: v-for WITH DESTRUCTURING -->
<!-- ============================================ -->
<template>
<ul>
<li v-for="{ id, name } in items" :key="id">
{{ name }}
</li>
</ul>
</template>
<script setup>
const items = [
{ id: 1, name: 'Apple', price: 1.00 },
{ id: 2, name: 'Banana', price: 0.50 }
]
</script>
<!-- ============================================ -->
<!-- PART 5: STATE MISMATCH WITHOUT KEY -->
<!-- ============================================ -->
<template>
<div>
<button @click="shuffle">Shuffle</button>
<ul>
<!-- BAD: using index as key -->
<li v-for="(item, index) in items" :key="index">
<input v-model="item.name">
{{ item.name }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' }
])
function shuffle() {
items.value = [...items.value].sort(() => Math.random() - 0.5)
}
</script>
<!-- ============================================ -->
<!-- PART 6: CORRECT KEY USAGE -->
<!-- ============================================ -->
<template>
<div>
<button @click="shuffle">Shuffle</button>
<ul>
<!-- GOOD: using unique id as key -->
<li v-for="item in items" :key="item.id">
<input v-model="item.name">
{{ item.name }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' }
])
function shuffle() {
items.value = [...items.value].sort(() => Math.random() - 0.5)
}
</script>
<!-- ============================================ -->
<!-- PART 7: v-for WITH COMPONENT -->
<!-- ============================================ -->
<template>
<ul>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
/>
</ul>
</template>
<script setup>
import TodoItem from './TodoItem.vue'
const todos = [
{ id: 1, text: 'Learn Vue', done: false },
{ id: 2, text: 'Build app', done: true }
]
</script>
<!-- ============================================ -->
<!-- PART 8: NESTED v-for -->
<!-- ============================================ -->
<template>
<ul>
<li v-for="group in groups" :key="group.id">
{{ group.name }}
<ul>
<li v-for="item in group.items" :key="item.id">
{{ item.name }}
</li>
</ul>
</li>
</ul>
</template>
<script setup>
const groups = [
{ id: 1, name: 'Fruits', items: [{ id: 1, name: 'Apple' }] },
{ id: 2, name: 'Vegetables', items: [{ id: 2, name: 'Carrot' }] }
]
</script>
<!-- ============================================ -->
<!-- PART 9: v-for ON TEMPLATE -->
<!-- ============================================ -->
<template>
<template v-for="item in items" :key="item.id">
<li>{{ item.name }}</li>
<li class="divider" role="presentation"></li>
</template>
</template>
<script setup>
const items = [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' }
]
</script>
<!-- ============================================ -->
<!-- PART 10: FILTERED LIST VIA COMPUTED -->
<!-- ============================================ -->
<template>
<ul>
<li v-for="item in activeItems" :key="item.id">
{{ item.name }}
</li>
</ul>
</template>
<script setup>
import { computed, ref } from 'vue'
const items = ref([
{ id: 1, name: 'Apple', active: true },
{ id: 2, name: 'Banana', active: false },
{ id: 3, name: 'Cherry', active: true }
])
const activeItems = computed(() =>
items.value.filter(item => item.active)
)
</script>
The ten parts covered the essential list rendering mechanics: basic iteration with key, index access, object iteration, destructuring, the state mismatch caused by index keys, correct key usage, component lists, nested loops, <template v-for>, and computed filtering.
Quick Reference
v-for Syntax Forms
| Form | Example | Notes |
|---|---|---|
| Basic array | v-for="item in items" | Most common |
| With index | v-for="(item, index) in items" | Index available |
| Object values | v-for="value in obj" | Iterates values |
| Object with key | v-for="(value, key) in obj" | Property names |
| Destructuring | v-for="{ id, name } in items" | Extract properties |
of delimiter | v-for="item of items" | JS iterator style |
Key Attribute Rules
| Rule | Consequence |
|---|---|
| Must be unique per parent | Duplicate keys cause render errors |
| Must be primitive (string, number, symbol) | Objects/arrays invalid |
Recommended with v-for always | ESLint vue/required-v-for-key |
Required for components in v-for | Cannot be inferred |
Placed on <template> for <template v-for> | Vue 3 change |
Key Choice Guidelines
| Key Choice | When Appropriate |
|---|---|
| Unique ID from data | Always, when available |
Composite key (e.g., JSON.stringify) | When no single unique property |
| Index | Static lists only, or append-only lists |
| No key | Simple, stateless content only |
Best Practices
✅ Do This:
<!-- Use unique ID as key -->
<li v-for="item in items" :key="item.id"> <!-- ✅ -->
<!-- Use composite key when no unique ID -->
<li v-for="item in items" :key="`${item.a}-${item.b}`"> <!-- ✅ -->
<!-- Place key on template for template v-for -->
<template v-for="item in items" :key="item.id"> <!-- ✅ -->
<!-- Filter with computed, not v-if -->
<li v-for="item in activeItems" :key="item.id"> <!-- ✅ -->
<!-- Provide keys for component lists -->
<TodoItem v-for="t in todos" :key="t.id" :todo="t" /> <!-- ✅ -->
❌ Don’t Do This:
<!-- Don't use index as key for dynamic lists -->
<li v-for="(item, i) in items" :key="i"> <!-- ❌ -->
<!-- Don't use non-primitive values as keys -->
<li v-for="item in items" :key="item"> <!-- ❌ -->
<!-- Don't use v-if and v-for on same element -->
<li v-for="item in items" v-if="item.active"> <!-- ❌ -->
<!-- Don't omit key when list has state -->
<li v-for="item in items"> <!-- ❌ -->
<input v-model="item.name">
</li>
<!-- Don't use duplicate keys -->
<li v-for="item in items" :key="'same'"> <!-- ❌ -->
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Input values mismatch after reorder | Index used as key | Use unique item ID |
| Duplicate key render error | Same key on multiple items | Ensure keys are unique |
| State resets on sort | Key changes when order changes | Use stable, identity-based key |
v-if and v-for conflict | v-if has higher priority in Vue 2 | Use computed filter instead |
Key not working on <template v-for> | Key placed on child, not template | Move key to <template> tag |
| Component list without key | Vue cannot track component identity | Add :key="item.id" |
Real-World Examples
1. Todo List with Stable Keys
<li v-for="todo in todos" :key="todo.id">
<input type="checkbox" v-model="todo.done">
{{ todo.text }}
</li>
2. Table Rows with IDs
<tr v-for="user in users" :key="user.id">
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</tr>
3. Select Options
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
4. Nested Comment Thread
<div v-for="comment in comments" :key="comment.id">
{{ comment.text }}
<div v-for="reply in comment.replies" :key="reply.id">
{{ reply.text }}
</div>
</div>
5. Grid of Cards
<div v-for="card in cards" :key="card.id" class="card">
<h3>{{ card.title }}</h3>
</div>
6. Filtered Search Results
<li v-for="result in filteredResults" :key="result.id">
{{ result.title }}
</li>
7. Object Key Iteration
<li v-for="(value, key) in settings" :key="key">
{{ key }}: {{ value }}
</li>
8. Dynamic Form Fields
<div v-for="field in fields" :key="field.id">
<label>{{ field.label }}</label>
<input v-model="field.value">
</div>
9. Tag List
<span v-for="tag in tags" :key="tag" class="tag">
{{ tag }}
</span>
10. Component List with Props
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
/>
Visual
In-Place Patch vs Keyed Reorder
┌─────────────────────────────────────────────────────────────┐
│ WITHOUT KEY: IN-PLACE PATCH │
│ │
│ Data: [A, B, C] Data: [C, A, B] │
│ │
│ DOM before: DOM after: │
│ ┌─────────┐ ┌─────────┐ │
│ │ <li> A │ │ <li> C │ ← content patched │
│ │ <li> B │ │ <li> A │ ← content patched │
│ │ <li> C │ │ <li> B │ ← content patched │
│ └─────────┘ └─────────┘ │
│ │
│ Elements stay in position. Content changes. │
│ State (inputs, components) stays with position. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WITH KEY: INTELLIGENT REORDER │
│ │
│ Data: [A(id1), B(id2), C(id3)] │
│ Data: [C(id3), A(id1), B(id2)] │
│ │
│ DOM before: DOM after: │
│ ┌─────────┐ ┌─────────┐ │
│ │ <li> A │ ──────────▶ │ <li> C │ ← moved from pos 3 │
│ │ <li> B │ ──────────▶ │ <li> A │ ← moved from pos 1 │
│ │ <li> C │ ──────────▶ │ <li> B │ ← moved from pos 2 │
│ └─────────┘ └─────────┘ │
│ │
│ Elements move with their identity. State stays with item. │
│ │
└─────────────────────────────────────────────────────────────┘
Key Selection Decision Tree
┌─────────────────────────────────────────────────────────────┐
│ CHOOSING A v-for KEY │
│ │
│ Does the item have a unique ID? │
│ │ │
│ ├── YES ──▶ Use :key="item.id" │
│ │ │
│ └── NO │
│ │ │
│ ├── Is there a combination of properties │
│ │ that is unique? │
│ │ └── YES ──▶ :key="`${item.a}-${item.b}`" │
│ │ │
│ └── Is the list static (never reorders)? │
│ ├── YES ──▶ Index acceptable │
│ └── NO ──▶ Generate stable IDs │
│ (e.g., crypto.randomUUID()) │
│ │
└─────────────────────────────────────────────────────────────┘
State Mismatch Demonstration
┌─────────────────────────────────────────────────────────────┐
│ WHY INDEX KEYS BREAK STATE │
│ │
│ Initial list: [Apple, Banana, Cherry] │
│ Keys (index): [0, 1, 2] │
│ │
│ User types "X" into Banana's input. │
│ Input at index 1 contains "XBanana". │
│ │
│ After shuffle: [Cherry, Apple, Banana] │
│ Keys (index): [0, 1, 2] ← keys didn't change! │
│ │
│ Vue sees same keys, patches content in place: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ index 0: "Cherry" input still has old value │ │
│ │ index 1: "Apple" input has "XBanana" ← WRONG │ │
│ │ index 2: "Banana" input still has old value │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ With ID keys, inputs move with their items. │
│ │
└─────────────────────────────────────────────────────────────┘
Virtual DOM Diffing with Keys
┌─────────────────────────────────────────────────────────────┐
│ KEYED DIFF ALGORITHM │
│ │
│ Old VNodes: New VNodes: │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ key: A │ │ key: B │ │
│ │ key: B │ │ key: C │ │
│ │ key: C │ │ key: A │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ Step 1: Map old keys to DOM nodes │
│ Step 2: Iterate new keys, find matching old nodes │
│ Step 3: Move matched nodes to new positions │
│ Step 4: Create nodes for new keys │
│ Step 5: Remove nodes for missing keys │
│ │
│ Result: Minimal DOM operations, state preserved. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Directive | v-for renders a list from an array or object |
| Syntax | item in items, (item, index) in items |
| Object iteration | (value, key) in obj |
| Destructuring | { id, name } in items |
| Key attribute | Hint for virtual DOM identity tracking |
| Key type | Primitive: string, number, symbol |
| Key uniqueness | Required within same parent |
| Without key | In-place patch, state mismatch risk |
| With index key | Same risk as no key for dynamic lists |
| With ID key | Correct state tracking, efficient reorder |
| Component lists | Key is required, cannot be inferred |
| Vue 3 change | Key on <template> for <template v-for> |
Key takeaways:
v-forrepeats an element for each item in a collection. The syntax mirrorsArray.forEach, with optional index or property-name aliases.- The
keyattribute gives Vue a way to track identity across re-renders. Without it, Vue patches elements by position, which is fast but wrong when list output depends on state. - Index as key is equivalent to no key for dynamic lists. The index represents position, not identity. When order changes, the index-key mapping changes, and Vue reuses elements incorrectly.
- State mismatch is the primary symptom of poor key choice. Input values, component internal state, and animations stay in their positional slots while the data they represent moves elsewhere.
- Keys must be unique and primitive. Duplicate keys cause render errors. Objects and arrays cannot be used as keys.
- Component lists require keys. Vue cannot infer identity for components in a
v-for, so thekeyattribute is mandatory. - Vue 3 changed
<template v-for>key placement. The key now goes on the<template>tag, not on individual children inside it. - Filter with computed properties, not
v-ifon the same element asv-for. The priority order in Vue 2 made this error-prone; Vue 3 warns against it. A computed filtered list is cleaner and more performant.
Remember: v-for and key are not separate concerns. v-for says “repeat this structure for each item.” key says “and here is how you know which item is which.” Without the second part, Vue is guessing at identity based on position, and when positions change, the guess is wrong. The key attribute is not boilerplate to be skimmed over—it is the mechanism that keeps your rendered list synchronized with your data. Choose it with the same care you choose your data structure, and your lists will remain stable, efficient, and correct through every reorder, insertion, and deletion.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!