The v-for Syntax Matrix: Arrays, Objects, Ranges, and Scope
v-for renders a template once per item in a source expression. The source can be an array, an object, a number (a range), or a string — and each form has its own destructuring aliases. Getting the alias order right is pure memorization, so here it is in one block:
<script setup lang="ts">
import { ref } from 'vue'
const inStock = ref(['SKU-1', 'SKU-2'])
const product = ref({ name: 'Keyboard', price: 79.9, stock: 12 })
</script>
<template>
<!-- array: (item, index) -->
<li v-for="(sku, i) in inStock" :key="sku">{{ i + 1 }}. {{ sku }}</li>
<!-- object: (value, key, index) -->
<tr v-for="(value, key, i) in product" :key="key">
<td>{{ key }}</td><td>{{ value }}</td><td>{{ i }}</td>
</tr>
<!-- number n: renders 1 through 5 -->
<li v-for="n in 5" :key="n">Row {{ n }}</li>
</template>
Note the object form's order — value first, then key, then index — which is the reverse of Object.entries() intuition and a reliable interview tripwire. Iteration order for objects follows Object.keys() semantics: integer-like keys first in ascending order, then string keys in insertion order. A numeric source renders 1..n, not 0..n-1.
Each iteration creates its own scope: the aliases (sku, i, value, key) exist only inside that iteration, and shadowing outer names is legal but confusing — if your loop variable is also called items, the inner one wins for the entire template inside the loop. When one item must produce several sibling nodes, wrap them in <template>, which renders no extra element:
<template v-for="item in items" :key="item.id">
<dt>{{ item.name }}</dt>
<dd>{{ item.detail }}</dd>
<hr />
</template>
<!-- key may also sit on <template> in Vue 3 -->
The :key deserves its own chapter next, but the one-line rule here: every v-for should carry a :key bound to a stable, per-item identifier. ESLint (vue/require-v-for-key) flags its absence, and devtools shows you why when a keyless list starts reusing the wrong nodes.
A scoping subtlety that matters in nested loops: inner-loop aliases shadow outer ones, and the standard fix is descriptive naming or destructure-into-different-names:
<li v-for="group in groups" :key="group.id">
<span v-for="member in group.members" :key="member.id">
{{ group.name }} — {{ member.name }}
</span>
</li>
Also remember the collision rule from Conditional Rendering with v-if: v-if and v-for on the same element resolves in favor of v-if, so per-item filtering lives either in a computed (preferred) or on an inner element where the item variable is in scope.
Finally, the template compiles to a plain array map plus the diff algorithm — there is no virtual-list by default. A 5,000-row v-for renders 5,000 rows and scrolls them all; if the list outgrows the viewport, windowing (Vue Virtual Scroller, TanStack Virtual) becomes a deliberate addition, not something Vue does for you.
A small detail about keys inside template wrappers closes the syntax tour: when v-for sits on <template>, the :key also goes on the template — Vue 3 supports it there, and putting keys on each child instead would demand unique values per child rather than per item, which is both harder and unnecessary. And if a list ever renders the same item twice by accident — an array containing duplicate ids, say — the dev console warns about duplicate keys immediately, because the diff would otherwise silently drop one of the matching nodes and the missing row would look like a data bug.
Why :key Is a Correctness Requirement, Not a Style Tip
Without a key, Vue's diff matches old and new nodes by position. That works while the list only ever appends — and quietly corrupts state the moment items move. Consider a checklist where the user has ticked the first row:
<!-- index key: identity follows position -->
<li v-for="(task, i) in tasks" :key="i">
<input type="checkbox" v-model="task.done" /> {{ task.title }}
</li>
<!-- stable key: identity follows the item -->
<li v-for="task in tasks" :key="task.id">
<input type="checkbox" v-model="task.done" /> {{ task.title }}
</li>
Unshift a new task onto the array. With :key="i", every item's key slides by one, so Vue's positional matching hands each existing DOM node to a different task — the checkbox you ticked stays ticked but now sits next to the wrong title, focus jumps, CSS transition classes fire on the wrong rows, and the component state baked into those nodes (inputs, scroll, video playback) follows positions instead of items. With :key="task.id", the new item's key is new, so Vue creates one node for it and reuses every other node for the task whose id it already had. The visible result matches the data, because identity matches the data.
What keyed diffing actually does: it lines up old and new children by key, reuses nodes whose keys match (patching their contents), creates nodes for new keys, and removes nodes whose keys vanished — using an optimized longest-in-subsequence pass, so an item moved from bottom to top costs one move instead of N patches. Keys must be unique among siblings (collisions make the diff pick the first match and drop the other) and stable for the item's lifetime — a random or timestamp key forces full re-creation on every render, which turns every keystroke into N component remounts.
When are index keys acceptable? The narrow case where all of the following hold: the list is append-only or never reordered, items hold no local state (no inputs, no focus, no in-flight requests keyed to the row), and filtering or sorting never happens upstream. "It's just a display list" satisfies that; "display list, but with a search box above it" does not — filtering changes what index 3 points to. The safe default is the id, and if your items lack one, crypto.randomUUID() at creation time beats any positional scheme.
Two practical extensions. Sorting and filtering change keys' meaning: sort in a computed rather than mutating the source array, so the rendered order is a function of state and the keyed diff animates rows into place. And component state rides along with the node: when v-for renders a component, the keyed node is the component instance — with stable keys, moving a row moves its instance, its onMounted does not re-fire, and its internal refs keep their values. That is the property that makes keyed lists feel solid: state belongs to the item, and the DOM follows.
A related devtools habit: when a list misbehaves, inspect two nodes before and after a mutation and compare their data-v attributes and DOM identity. If the nodes changed owners, the bug is keying, not your data logic — a fifteen-second diagnosis once you know what keyed diffing promises.
Mutating Reactive Arrays: What Triggers, What Re-renders, and What Touches
A reactive array is a Proxy over the real one, and every tracked operation goes through it — reads during render register dependencies; writes trigger exactly the effects that read what changed. Both halves matter for lists.
Mutation methods are first-class. push, pop, shift, unshift, splice, sort, and reverse — the seven that mutate in place — are wrapped by the proxy, so calling them triggers re-renders immediately:
const cart = ref([{ id: 'a', qty: 1 }])
cart.value.push({ id: 'b', qty: 2 }) // renders the new row
cart.value.splice(0, 1) // removes row 0
cart.value = cart.value.filter(i => i.qty > 0) // replace via the ref — also triggers
"Targeted re-render" is the phrase worth understanding: a push does not rebuild the list from scratch. The render effect re-runs, produces a new set of keyed nodes, and the diff reuses every existing node for its key — one creation, zero patches elsewhere. That is the payoff of the :key discipline from the previous chapter: the mutation method triggers the effect; the key decides how little DOM work follows.
Direct index and length writes are tracked in Vue 3. This is a real upgrade over Vue 2, where cart.value[0] = x silently updated the array without re-rendering — the Vue 2 advice to use splice or Vue.set for index assignment no longer applies, because the proxy's set trap fires for index keys the same as any other. Writing cart.value[0].qty = 5 also triggers, since the nested object is itself reactive. (The classic object gap — adding a brand-new property — is a separate story covered in the pitfalls topic; for arrays, index writes are safe.)
Reads that miss tracking are the remaining trap. Destructure the array out of its reactive parent, or spread it into a plain local, and the link is cut:
const state = reactive({ items: ['a', 'b'] })
const { items } = state // plain array — later pushes do NOT re-render
const copy = [...state.items] // snapshot — same problem
The template does the right thing only because it reads state.items through the proxy on every render. In script, either read through the parent (state.items.push(...)) or keep the list in a ref/reactive of its own so every access routes through the trap.
Order and identity also trigger. sort and reverse mutate in place (they are on the patch list — no warning, unlike Vue 2's famous "Do not mutate sort/reverse" console error, because in Vue 3 they legitimately trigger updates). Replacing the entire array through the ref (cart.value = [...]) is equally valid and often clearer in functional pipelines — the ref's setter triggers with the new array, and the keyed diff figures out the moves. A pure-functional style — computed for sorted/filtered views, mutations only in actions — pairs well with keyed lists because every derived order is a function of state, and the DOM animation follows the reorder for free.
One performance footnote: push in a loop is fine (one re-render per flush, since Vue batches updates in the same tick), but the pattern worth knowing for bulk loads is assigning the whole array once — cart.value = Array.from({ length: 1000 }, makeItem) — which is one trigger, one render, one diff. You now have the complete rendering triangle: expressions produce values, conditionals decide existence, v-for plus keys decides identity. Next: The script setup Syntax, where the script block that feeds all of this compiles down to component internals.
Quiz
1. A to-do list uses `:key="i"` (the array index). The user ticks the first checkbox, then a new task is unshifted at the front. What happens?
2. What does the object form of v-for destructure, and in what order?
3. Which operations on a reactive array trigger re-renders in Vue 3?
4. Why should a sorting control read from a `computed` instead of calling `items.sort()` in an event handler?
Flashcards
Question
What are the v-for alias orders for arrays, objects, and numbers?
Click to reveal answer
Answer
Array: (item, index) in items. Object: (value, key, index) in object — value FIRST (reverse of Object.entries intuition). Number: v-for="n in 5" renders 1..5. Each iteration has its own scope; wrap multi-node items in <template v-for> (which may carry the :key).
Question
What does :key buy you, mechanically?
Click to reveal answer
Answer
It gives each child node identity across renders: the diff creates/patches/moves nodes by matching keys (with a longest-in-subsequence move optimization), so state embedded in the DOM (checkbox, focus, transitions, component instances) follows the item instead of the position. Without it, matching is positional and state drifts on reorder/insert.
Question
When is :key="index" acceptable?
Click to reveal answer
Answer
Only when the list is append-only/never reordered, upstream filtering never changes positions, and rows hold no DOM-embedded state. Any sort, search, or input in a row breaks it — ids (or crypto.randomUUID() at creation) are the default; keys must be unique among siblings and stable for the item's lifetime.
Question
Which array operations trigger updates in Vue 3, and how do they differ from Vue 2?
Click to reveal answer
Answer
All seven in-place mutators (push, pop, shift, unshift, splice, sort, reverse) trigger, whole-array replacement via the ref setter triggers, and — unlike Vue 2 — direct index and length writes trigger too. sort/reverse mutating in place is legal in Vue 3 (no console warning), unlike Vue 2's notorious warning.
Question
How do a mutation method and a :key split the work of a list update?
Click to reveal answer
Answer
The mutation triggers the render effect; the keys decide the DOM cost. One push with stable keys = one node created, everything else reused by the diff. The same mutation with index keys or no keys can patch or corrupt many nodes — the trigger is automatic, the efficiency is keying.
Revision Notes
Key Takeaways
- 1. v-for aliases are positional — array (item, index), object (value, key, index), number 1..n — and each iteration owns its scope; <template v-for> groups nodes without extra DOM
- 2. :key converts the diff from positional to identity-based, so component instances, focus, and checkbox state follow items through inserts and sorts
- 3. Vue 3 tracks the seven mutators, whole-array replacement, and even direct index/length writes — the Vue 2 splice-for-index folklore no longer applies
- 4. The mutation triggers the effect; the key decides the DOM cost — stable keys make a push one created node, everything else reused
- 5. Destructuring or spreading a reactive array severs tracking in script — keep list state inside a ref/reactive container and read through the proxy
Interview Tips
- • "Why do you need :key in v-for?" — explain positional vs identity matching, the checkbox-on-the-wrong-row symptom, and the keyed diff's create/patch/move behavior — this is the single most asked rendering question
- • "Index key — ever acceptable?" — the append-only, no-row-state, no-filtering case; state your default is an id and why random keys are worse (full remount each render)
- • "How does Vue reactivity handle arrays?" — method wrapping via proxy, batched updates in a tick, and the Vue 3 upgrade over Vue 2 for index assignment; mention computed for derived order
- • "List of 50,000 rows — what breaks?" — no implicit windowing: v-for mounts everything; solution is virtual scrolling plus keys, showing you know the boundary of the built-in renderer
Cheat Sheet
List Rendering: v-for
Syntax:
<li v-for="(item, i) in items" :key="item.id">…</li> <!-- array -->
<tr v-for="(value, key, i) in obj" :key="key">…</tr> <!-- object: value FIRST -->
<li v-for="n in 5" :key="n">Row {{ n }}</li> <!-- number: 1..n -->
<template v-for="it in items" :key="it.id"><dt/><dd/></template>
Key rules:
| Rule | Why |
|---|---|
always bind :key |
positional matching corrupts DOM-embedded state on reorder/insert |
| stable, unique ids | diff reuses nodes by identity; duplicates make the diff drop matches |
| index keys only if append-only + no row state + no filtering | inserting shifts every key one position |
sort/filter via computed |
order becomes a function of state; keyed diff moves rows |
Reactivity in arrays (Vue 3): mutation methods trigger · whole-array replacement via ref.value = [...] triggers · direct index/length writes also trigger (Vue 2 limitation, now gone) · nested objects are reactive too · destructuring/spreading breaks the link — read through the proxy or keep your own ref.
Perf notes: pushes in a loop batch into one render; bulk loads assign the whole array once; long lists (thousands) need explicit virtual scrolling — v-for renders every item.
Don't: v-if on the same element as v-for (v-if wins; filter with computed) · random/timestamp keys (full remount per render) · mutating the source array from a view component (do it in the store/action).