Vue 3's v-for: Common Patterns, Eight Traps, and the Key Mistakes That Break Lists
Vue 3 v-for: Common Usages and High-Frequency Pitfalls in Development
Preface:
v-for is the highest-frequency list rendering directive in Vue 3. If you write Vue pages, you deal with v-for almost every day: tables, dropdown options, card lists, dynamic forms... all of these rely on it for looping rendering. Many people think v-for is simple, but during development they often run into problems such as: list data getting out of order, views not updating, console errors, and input field values getting mixed up. Most of these problems are not Vue bugs; they come from not paying attention to v-for usage details.
1. Common v-for usages
1.1 Iterating over an array (most common)
Syntax: v-for="(item, index) in array"
- item: current item in the array
- index: current index
<template>
<ul>
<li v-for="(item, index) in userList" :key="item.id">
{{ index + 1 }}. {{ item.name }} - {{ item.age }}
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const userList = ref([
{ id: 1, name: 'Zhang San', age: 22 },
{ id: 2, name: 'Li Si', age: 25 },
{ id: 3, name: 'Wang Wu', age: 21 }
])
</script>
1.2 Iterating over an object
Syntax: v-for="(value, key, index) in object"
Remember the order: value, key, index.
<template>
<div v-for="(val, key, idx) in info" :key="key">
{{ idx }} | {{ key }}: {{ val }}
</div>
</template>
<script setup>
import { reactive } from 'vue'
const info = reactive({
name: 'Xiaoming',
gender: 'male',
address: 'Shanghai'
})
</script>
1.3 Iterating over a number, looping a fixed number of times
You can pass a number directly; it loops starting from 1:
<!-- Output: 1 2 3 4 5 -->
<span v-for="n in 5" :key="n">{{ n }}</span>
This is suitable for pagination page numbers and fixed-count placeholder skeleton screens.
1.4 Using template for looping without generating extra DOM
<template> is a placeholder tag. It does not generate real DOM after rendering. When you want to loop multiple tags without wrapping them in a div, use it:
<template>
<template v-for="item in goodsList" :key="item.id">
<p class="title">{{ item.title }}</p>
<p class="price">¥{{ item.price }}</p>
</template>
</template>
1.5 Looping over custom components
Looping over components is written the same way as normal tags. The key is also required, and props are passed normally:
<GoodsCard
v-for="item in goodsList"
:key="item.id"
:goods="item"
/>
1.6 List filtering: prefer computed
Do not write filtering logic directly inside v-for. Keep templates simple, and hand complex filtering to computed properties for better caching and performance:
<template>
<li v-for="item in hotList" :key="item.id">{{ item.title }}</li>
</template>
<script setup>
import { ref, computed } from 'vue'
const goodsList = ref([
{ id: 1, title: 'Product A', hot: true },
{ id: 2, title: 'Product B', hot: false },
{ id: 3, title: 'Product C', hot: true }
])
// Show only hot products
const hotList = computed(() => {
return goodsList.value.filter(item => item.hot)
})
</script>
2. High-frequency pitfalls and solutions
Pitfall 1: Putting v-for and v-if on the same element (the biggest pitfall). The Vue 3 official documentation explicitly discourages v-for and v-if coexisting on the same element.
Wrong approach:
<!-- Not recommended! v-for has higher priority than v-if, so it loops over everything first and then checks the condition. Poor performance, and logic is easy to break. -->
<li v-for="item in list" v-if="item.isShow" :key="item.id">
{{ item.name }}
</li>
Two recommended solutions:
- Wrap with template and put v-for on the outer template:
<template v-for="item in list" :key="item.id">
<li v-if="item.isShow">{{ item.name }}</li>
</template>
- Prefer computed to pre-filter the array (best performance).
Pitfall 2: Binding key to index. After adding, removing, or dragging items, data and DOM get misaligned.
Wrong approach:
<li v-for="(item, index) in list" :key="index">{{ item.name }}</li>
Problem: when items are deleted, inserted, or reordered in the middle of the array, the index changes, and Vue's diff algorithm reuses the wrong DOM nodes. Input values get mixed up, and data no longer matches the UI. This pitfall is especially fatal in lists with forms.
Recommended approach:
Use a unique business ID:
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
There is only one special case where index can be used: the list is statically written out and will never add, delete, or reorder items. For dynamic business lists, never use index as the key.
Pitfall 3: Assigning values by array index directly does not update the view
Approach that is easy to fail:
// Directly assigning by index. Vue 3 Proxy is better than Vue 2, but in complex scenarios the view still may not update.
list.value[0] = { id: 99, name: 'Test' }
Solutions:
- Use splice to modify:
list.value.splice(0, 1, { id: 99, name: 'Test' })
- Replace the array reference:
list.value = [...list.value]
Pitfall 4: Duplicate key values trigger console warnings and render corruption
When keys are duplicated within the same loop, Vue throws a warning, DOM gets randomly corrupted, and it is hard to reproduce and debug.
<!-- Two items have the same id, so keys are duplicated -->
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
Causes: the backend returns duplicate ids, or mock data accidentally has duplicates.
Solution: ensure the key is unique within the current loop; when there is no unique id, generate a uuid on the frontend.
Pitfall 5: Collecting refs in a component loop; using a plain ref cannot get the component instance
Wrong approach:
<MyInput v-for="item in list" :key="item.id" ref="inputRef" />
With this, inputRef only gets the last component instance.
Recommended approach:
In Vue 3, use a function ref to collect:
<MyInput v-for="item in list" :key="item.id" :ref="setRef" />
<script setup>
const compList = []
const setRef = (el) => {
if (el) compList.push(el)
}
</script>
Pitfall 6: Binding v-model on looped inputs to the same variable makes them all linked
Wrong approach:
<input v-for="item in list" :key="item.id" v-model="inputVal" />
All inputs bind to the same variable; changing one changes all of them.
Recommended approach:
Bind to the looped item's own property:
<input v-for="item in list" :key="item.id" v-model="item.name" />
Pitfall 7: Async API data starts as null/undefined and the page errors out
Wrong approach:
Before the API returns, list is null by default. v-for looping throws an iterator error: Cannot read property 'Symbol(Symbol.iterator)' of null
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
Recommended solutions:
Initialize to an empty array:
const list = ref([])
// Assign after the API returns
Or add an empty-state check:
<ul v-if="list.length">
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
</ul>
<div v-else>No data</div>
Pitfall 8: Adding new properties dynamically to a reactive object does not update the view
const user = reactive({ name: 'Zhang San' })
// Adding a property directly may not trigger updates in some scenarios
user.age = 20
Solutions:
Replace the object reference:
Object.assign(user, { age: 20 })
// Or
const newUser = { ...user, age: 20 }
Object.assign(user, newUser)
3. Best practice summary
v-formust includekey; prefer a unique business ID and avoid index when possible.- Do not put
v-forandv-ifon the same element; filter lists with computed first. - Avoid modifying arrays directly by index; prefer splice or replacing the reference.
- Initialize async lists to an empty array to prevent render errors.
- Use function refs to collect refs from looped components.
- Use template tags to group rendering and reduce unnecessary DOM.
- Keep template logic simple; move complex calculations and filtering to the script.
v-for looks simple, but many pitfalls only surface when operations like adding, removing, and sorting go live, especially in dynamic forms and draggable lists. As long as you control the three key points, key, the priority of v-if and v-for, and reactive updates, you can avoid the vast majority of problems.