Skip to content
wannacode

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.

Vue

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
  1. 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.
  2. "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.
  3. 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.
  4. 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.
  5. The same cart with key = id. The keys are 1, 2, 3 and belong to the product, not to the place.
  6. "Remove White bread" again. The new list has the keys 2 and 3.
  7. 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.
  8. 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.

Vue

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>

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.
Vue

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.

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.

  • filter returns a new array with only the products in stock. The original stays whole.
  • sort sorts the array in place. Here it is safe, because it comes after filter, 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".

Vue
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.

Vue

Test

Check yourself

1. How does the library tell which row of the old list is which row of the new one?
2. The key is the index. The buyer removes the first product. What happens to the fields of the other rows?
3. Why is Math.random() a bad key?
4. The list has to be sorted by price. How?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·