Lists and keys
In short: A list in Vue is made with v-for from an array to components, and :key tells Vue which old row is which new one; a random number is a bad key, and so is the index once the list changes.
Problem
I removed the bread, and the milk became 3
The buyer has bread, milk and cheese in the cart. They type 3 for the bread and 2 for the milk, and then remove the bread. Try it: change the two numbers and press "Remove" next to the bread.
Console
The bread is gone, but the milk is now 3 and the cheese is 2. There is no error in the console. The data are right: exactly the bread was removed. Only the fields are mixed up. The cause is one attribute on line 22 of App.vue: what the code tells the library about every row of the list.
The idea
A list in a component
In the DOM module a list was made from an array with map and createElement. In a component the idea is the same, but every item of the array becomes part of the markup:
<ul> <li v-for="p in items" :key="p.id"> <ProductCard :product="p" /> </li></ul>v-for="p in items" repeats <li> for every product, and inside it p is the current product. :key is the key by which Vue recognizes every row.
When the data change, the component returns a new list. The library does not delete the old one to draw it again. It compares the old and the new one and changes only the difference on the page. That is why the focus and what was typed into fields stay, instead of getting lost as with render() in the DOM module.
To compare, it has to know which old row matches which new row. That is what the key says.
The idea
Pairing by key
The library pairs the old and the new rows by key:
- The same key is the same row. The element on the page stays, and the library changes in it only what differs in the data: the name, the price.
- A key that is not in the new list is a removed row. Its element is removed from the page.
- A new key is a new row. A new element is made.
The element that stays keeps everything of its own: what was typed into an <input>, the focus, an open drop-down. These are not in the data but in the element itself. That is why it matters that it stays with its product.
In the example the key is the index: the place in the array. After "Remove" the milk is at place 0. The old row with key 0 belonged to the bread. The library keeps it, changes its name to "Fresh milk" and leaves the field with 3.
See it
See the pairing
On the left is the page before "Remove", on the right the new list from the component. Watch which row goes.
<!-- key = the place in the list --><li v-for="(p, index) in items" :key="index"> <ProductCard :product="p" /></li>
<!-- key = the id of the product --><li v-for="p in items" :key="p.id"> <ProductCard :product="p" /></li>Step by step, in words
- Three rows in the cart, and the key is the place: 0, 1, 2. The buyer has typed 3, 2 and 1 into the fields. What was typed lives in the <input> element, not in the data.
- "Remove White bread". The data now hold two products, and the component returns a new list: key 0 is the milk, key 1 is the cheese.
- The library pairs the old and the new list by key: 0 with 0, 1 with 1. Key 2 is not in the new list, so the third row goes: the one of the cheese.
- The kept rows get the new data, and their headings now say milk and cheese. The fields are the old ones, with 3 and 2. The quantity of the bread is now on the milk.
- The same cart with key = id. The keys are 1, 2, 3 and belong to the product, not to the place.
- "Remove White bread" again. The new list has the keys 2 and 3.
- Key 2 pairs with key 2, and 3 with 3, even though they moved up. Key 1 is missing, so the row of the bread goes, together with its field.
- Every field stayed with its product: milk 2, cheese 1. The key tells the library who is who, not where it is.
With the index the last row goes, and the first two change their names. With the id the row of the bread goes, and the other two only move up with their fields. In both cases the data are the same. The difference is what the key says: the place or the product.
Try it
Fix the key
Fix the cart so that after "Remove" every number stays with its product. The check types 3 for the bread and 2 for the milk, removes the bread and looks at the fields of the milk and the cheese.
Console
Solution
App.vue<script setup>
import { ref } from 'vue';
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 2, name: 'Fresh milk', price: 1.6 },
{ id: 3, name: 'White cheese', price: 7.9 },
];
// The state (next module): the list that the buttons change.
const items = ref(PRODUCTS);
const remove = (id) => {
items.value = items.value.filter((p) => p.id !== id);
};
</script>
<template>
<main>
<h1>The cart</h1>
<ul>
<li v-for="p in items" :key="p.id">
<ProductCard :product="p" />
<button type="button" :aria-label="`Remove ${p.name}`" @click="remove(p.id)">Remove</button>
</li>
</ul>
</main>
</template>
ProductCard.vue<script setup>
defineProps({ product: Object });
const money = (x) => '€' + x.toFixed(2);
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>{{ money(product.price) }}</p>
<label>
Quantity <input type="number" min="1" value="1" :aria-label="`Quantity: ${product.name}`" />
</label>
</article>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
The key becomes the id of the product. It is the same on every render and different for every product. After "Remove" key 1 is missing, so the row of the bread goes, and the rows of the milk and the cheese stay with their fields. Without a key the library again compares by place and the bug stays.
Try it
Fill in
What is the attribute by which the library recognizes a row?
<li v-for="p in items" ="p.id">
<ProductCard :product="p" />
</li>:key: the colon says that the value is an expression (p.id), not the text "p.id".
The idea
A good key
A good key is stable and unique: the same for the same product on every render, and different for different products. The best one is an id from the data: it comes from the database and does not change.
- The index is a bad key if the list changes: removing, adding at the start, sorting, filtering. It is fine if the list is read only and never changes its order.
Math.random()is always a bad key. On every render the key is new, so no old row has a pair. The library removes all rows and makes new ones. The fields lose what was typed, and the focus disappears.- Two equal keys confuse the pairing. Here the key is the name, and there are two sizes of "Yogurt". Press "Sort by price" and count the rows.
Console
There are four products, and the rows become five: one yogurt is there twice.
Vue warns only on the update: "Duplicate keys found during update: "Yogurt"". On the first render it is silent. If you forget:key, Vue says nothing at all: it compares the rows by place. That is why the Vue linter (eslint-plugin-vue) asks for a key on every v-for. The warnings are only in development mode, and the bug on the page stays on the real site too.Try it
Find the bug
On every render all "Quantity" fields go back to 1, and the caret leaves the field.
Pick the line with the bug.
Line 2: :key="Math.random()" is a new key on every render. No old row has a pair, and Vue makes all of them again. The key has to be p.id.
The idea
Filtering and sorting
The catalog shows only the products in stock, sorted by price. This is a derived list: it is not stored separately but made from the data on every render.
const visible = PRODUCTS .filter((p) => p.stock > 0) .sort((a, b) => a.price - b.price);This is in <script setup>. Here PRODUCTS does not change, and computing it once is enough. For data that change, Vue has computed. The lesson "Derived state" is about it.
filterreturns a new array with only the products in stock. The original stays whole.sortsorts the array in place. Here it is safe, because it comes afterfilter, which already gave a copy. Without a filter you make a copy first:[...PRODUCTS].sort(…).- The key stays
p.id. When sorting, the rows change places, and the index would change the key of every one of them.
Try it
Only what is in stock
Show only the products in stock (stock > 0), sorted by price from the cheapest. Above the list write "In stock: 3 of 5".
Console
Solution
App.vue<script setup>
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 3, name: 'White cheese', price: 7.9, stock: 4 },
{ id: 4, name: 'Banitsa', price: 2.5, stock: 0 },
{ id: 1, name: 'White bread', price: 1.2, stock: 10 },
{ id: 5, name: 'Cucumbers', price: 2.2, stock: 0 },
{ id: 2, name: 'Fresh milk', price: 1.6, stock: 6 },
];
const visible = PRODUCTS.filter((p) => p.stock > 0).sort((a, b) => a.price - b.price);
</script>
<template>
<main>
<h1>The catalog</h1>
<p>In stock: {{ visible.length }} of {{ PRODUCTS.length }}</p>
<ul>
<li v-for="p in visible" :key="p.id">
<ProductCard :product="p" />
</li>
</ul>
</main>
</template>
ProductCard.vue<script setup>
defineProps({ product: Object });
const money = (x) => '€' + x.toFixed(2);
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>{{ money(product.price) }}</p>
<label>
Quantity <input type="number" min="1" value="1" :aria-label="`Quantity: ${product.name}`" />
</label>
</article>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
PRODUCTS.filter((p) => p.stock > 0) drops the banitsa and the cucumbers, and .sort((a, b) => a.price - b.price) orders the bread, the milk and the cheese. The numbers come from the two arrays: visible.length and PRODUCTS.length. The check looks at the order of the headings. It does not see the key: nothing in this list changes after rendering. The key matters as soon as something changes, as in the exercise.
More precisely, if you are curious
key is not a prop. The library takes the key for itself and does not pass it to the component. defineProps(['key']) gives the warning ""key" is a reserved property". If the component needs the id, pass it separately: product already contains it.
A key to reset. A new key means a new element. So a changed key is a way to reset a component: a form keyed by the id of the product starts empty for every new product.
Several elements per row. If one product is two elements (<dt> and <dd>), v-for and :key go on a <template>: <template v-for="p in items" :key="p.id">.
v-if and v-for on one element. In Vue 3 v-if runs before v-for and does not see p. The filter belongs in the data (visible), not in a v-if on the same element.
Options API. Old Vue code uses data() and methods instead of <script setup>. v-for and :key are the same.
toSorted. products.toSorted((a, b) => a.price - b.price) returns a sorted copy and does not touch the original. It is in browsers since 2023.
Why the field is "uncontrolled". Here the number in the field is only in the DOM. In the "State" module the field becomes controlled: the number is in the data and follows them. Then the key still matters: for the focus, the caret and the state that the component keeps inside itself.
Exercise
The cart
The state and the "Sort by price" button are ready. Make the row for every product: ProductCard and a "Remove" button whose name for a screen reader is "Remove White bread". The check types quantities, sorts, removes the bread and looks that every number stays with its product.
Console
Solution
App.vue<script setup>
import { ref } from 'vue';
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 3, name: 'White cheese', price: 7.9 },
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 2, name: 'Fresh milk', price: 1.6 },
];
// The state (next module): the list that the buttons change.
const items = ref(PRODUCTS);
const remove = (id) => {
items.value = items.value.filter((p) => p.id !== id);
};
const sortByPrice = () => {
items.value = [...items.value].sort((a, b) => a.price - b.price);
};
</script>
<template>
<main>
<h1>The cart ({{ items.length }})</h1>
<ul>
<li v-for="p in items" :key="p.id">
<ProductCard :product="p" />
<button type="button" :aria-label="`Remove ${p.name}`" @click="remove(p.id)">Remove</button>
</li>
</ul>
<button type="button" @click="sortByPrice">Sort by price</button>
</main>
</template>
ProductCard.vue<script setup>
defineProps({ product: Object });
const money = (x) => '€' + x.toFixed(2);
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>{{ money(product.price) }}</p>
<label>
Quantity <input type="number" min="1" value="1" :aria-label="`Quantity: ${product.name}`" />
</label>
</article>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
The row is an <li> with the key p.id, ProductCard and a button. The button calls remove(p.id), and its name for a screen reader comes from aria-label. With the index as the key, sorting moves the numbers: the cheese was first, so the 4 stays on the first row, which is now the bread. With Math.random() every sort sets the fields back to 1.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Events · A list from data · The first component · Props: data from above
- Continues in: Conditions: empty, error, loading · State · Updating objects and arrays · Derived state
- Tasks on this topic: Components