# List Rendering

Course: [Vue.js](https://www.devtutorials.ai/learn/vue) · Section: Rendering
Source: https://www.devtutorials.ai/learn/vue/rendering/list-rendering

> v-for over arrays, objects and ranges, item, index, key and value, why every item needs a stable key and why not the index, v-for on <template>, components in lists, why v-if and v-for do not share an element, filtering and sorting with computed, and array change detection.

## Lists

- Most pages show lists: dishes, orders, reviews.
- Writing one `<li>` per item by hand does not work when the data changes.
- `v-for` repeats an element once for each item in your data.
- When the data changes, the list on screen changes too.

**Simple lesson:** `v-for` turns a list of data into a list on the page.

### A Real-World Example

- Meera keeps her menu as an array of dishes.
- The page must show one row per dish, with its price.
- When she adds a dish, a new row must appear.

interface Dish {
    id: number;
    name: string;
    price: number;
}

const menu = ref<Dish[]>([
    { id: 1, name: "Masala Dosa", price: 90 },
    { id: 2, name: "Idli", price: 50 },
    { id: 3, name: "Vada", price: 40 },
]);

let nextId = 4;
function addPongal(): void {
    menu.value.push({ id: nextId++, name: "Pongal", price: 70 });
}
</script>

<template>
    <ul>
      <li v-for="dish in menu" :key="dish.id">{{ dish.name }}: ₹{{ dish.price }}</li>
    </ul>
    <button @click="addPongal">Add Pongal</button>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

- **What happened:** one `<li>` was made for each dish.
- `push` added a dish, and a new row appeared.

### What You Will Learn

1. How to loop over an array with `v-for`.
2. How to get the index too.
3. How to loop over an object and a number range.
4. Why every item needs a `:key`.
5. Why the index is a bad key.
6. How to repeat a group with `<template v-for>`.
7. How to show components in a list.
8. How to filter and sort with `computed`.
9. Which array changes Vue sees.

## v-for over Arrays

- `v-for="item in items"` repeats the element for each item.
- `item` is a name you choose. It holds the current item.
- `v-for="(item, index) in items"` also gives the position, starting at 0.
- You can use `of` instead of `in`. They work the same.
- You can destructure the item: `v-for="({ id, name }, index) in dishes"`.

const queue: string[] = ["Asha", "Ravi", "Meena"];
</script>

<template>
    <ol>
      <li v-for="(name, index) in queue" :key="name">
        {{ name }} is at position {{ index }}
      </li>
    </ol>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

### v-for over Objects

- `v-for="value in object"` loops over the values.
- `(value, key)` adds the property name.
- `(value, key, index)` adds the position.

const timings = {
    breakfast: "7 to 11",
    lunch: "12 to 4",
    dinner: "7 to 10",
};
</script>

<template>
    <ul>
      <li v-for="(hours, meal, i) in timings" :key="meal">{{ i + 1 }}. {{ meal }}: {{ hours }}</li>
    </ul>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

### v-for over a Range

- `v-for="n in 5"` repeats 5 times.
- `n` starts at **1**, not 0. It goes 1, 2, 3, 4, 5.

    <button v-for="n in 5" :key="n" class="table">Table {{ n }}</button>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

**Simple lesson:** `v-for` loops over arrays, objects and number ranges.

## Keys

- `:key` gives each item a name Vue can recognise.
- When the list changes, Vue matches old and new items by their key.
- A matched item is moved, not rebuilt. Its state stays with it.
- A key must be **unique** and **stable**. Stable = the same item always has the same key.
- An id from your data is the best key.

<figure className="vue-fig ngc-fig">
<svg viewBox="0 0 640 220" role="img" aria-labelledby="fig-vue-keys" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-vue-keys">Animation: Dosa is removed from the top of a list. With index keys, the note box that belonged to Dosa now sits next to Idli. With id keys, each note stays with its own dish</title>
  <g style={{ fontFamily: "var(--sans)", fontSize: 14, fontWeight: 700 }}>
    <text x="160" y="22" textAnchor="middle" style={{ fill: "var(--ngc-red)" }}>:key="index"</text>
    <text x="480" y="22" textAnchor="middle" style={{ fill: "var(--ngc-green)" }}>:key="dish.id"</text>
  </g>
  <g style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
    <g><animate attributeName="opacity" values="1;1;0;0;1" keyTimes="0;0.35;0.45;0.95;1" dur="7s" repeatCount="indefinite" /><rect x="40" y="40" width="110" height="34" rx="8" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }} /><text x="95" y="62" textAnchor="middle" style={{ fill: "var(--ngc-blue)" }}>Dosa</text></g>
    <rect x="40" y="84" width="110" height="34" rx="8" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }}><animate attributeName="y" values="84;84;40;40;84" keyTimes="0;0.45;0.55;0.95;1" dur="7s" repeatCount="indefinite" /></rect>
    <text x="95" y="106" textAnchor="middle" style={{ fill: "var(--ngc-blue)" }}><animate attributeName="y" values="106;106;62;62;106" keyTimes="0;0.45;0.55;0.95;1" dur="7s" repeatCount="indefinite" />Idli</text>
    <rect x="165" y="40" width="120" height="34" rx="8" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} /><text x="225" y="62" textAnchor="middle" style={{ fill: "var(--ngc-slate)" }}>"extra chutney"</text>
    <rect x="165" y="84" width="120" height="34" rx="8" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }}><animate attributeName="opacity" values="1;1;0;0;1" keyTimes="0;0.45;0.5;0.95;1" dur="7s" repeatCount="indefinite" /></rect><text x="225" y="106" textAnchor="middle" style={{ fill: "var(--ngc-slate)" }}><animate attributeName="opacity" values="1;1;0;0;1" keyTimes="0;0.45;0.5;0.95;1" dur="7s" repeatCount="indefinite" />"no onion"</text>
    <text x="160" y="160" textAnchor="middle" opacity="0" style={{ fill: "var(--ngc-red)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.55;0.6;0.95;1" dur="7s" repeatCount="indefinite" />Idli now has Dosa's note!</text>
    <g><animate attributeName="opacity" values="1;1;0;0;1" keyTimes="0;0.35;0.45;0.95;1" dur="7s" repeatCount="indefinite" /><rect x="360" y="40" width="110" height="34" rx="8" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }} /><text x="415" y="62" textAnchor="middle" style={{ fill: "var(--ngc-blue)" }}>Dosa</text><rect x="485" y="40" width="120" height="34" rx="8" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} /><text x="545" y="62" textAnchor="middle" style={{ fill: "var(--ngc-slate)" }}>"extra chutney"</text></g>
    <g><animateTransform attributeName="transform" type="translate" values="0 0;0 0;0 -44;0 -44;0 0" keyTimes="0;0.45;0.55;0.95;1" dur="7s" repeatCount="indefinite" /><rect x="360" y="84" width="110" height="34" rx="8" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }} /><text x="415" y="106" textAnchor="middle" style={{ fill: "var(--ngc-blue)" }}>Idli</text><rect x="485" y="84" width="120" height="34" rx="8" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} /><text x="545" y="106" textAnchor="middle" style={{ fill: "var(--ngc-slate)" }}>"no onion"</text></g>
    <text x="480" y="160" textAnchor="middle" opacity="0" style={{ fill: "var(--ngc-green)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.55;0.6;0.95;1" dur="7s" repeatCount="indefinite" />Each note moved with its dish.</text>
  </g>
  <text x="320" y="205" textAnchor="middle" style={{ fill: "var(--ngc-slate)", fontFamily: "var(--sans)", fontSize: 13 }}>Remove Dosa from the top of the list.</text>
</svg>
<figcaption>Index keys shift when an item is removed. Id keys stay with their item.</figcaption>
</figure>

### Why Not the Index?

- The index of an item changes when items are added, removed or sorted.
- Vue then thinks a different item is the old one, and reuses the wrong element.
- Anything typed into that element stays in the wrong row.
- Try it: type a note next to **Dosa** in both lists, then press **Remove first**.

const dishes = ref([
    { id: 1, name: "Dosa" },
    { id: 2, name: "Idli" },
    { id: 3, name: "Vada" },
]);

function removeFirst(): void {
    dishes.value.shift();
}
</script>

<template>
    <h4>:key="index"</h4>
    <p v-for="(dish, index) in dishes" :key="index">{{ dish.name }} <input placeholder="note" /></p>
    <h4>:key="dish.id"</h4>
    <p v-for="dish in dishes" :key="dish.id">{{ dish.name }} <input placeholder="note" /></p>
    <button @click="removeFirst">Remove first</button>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

- **What happened:** in the first list, your Dosa note jumped to Idli.
- In the second list, the note left with Dosa.

Never use the index or `Math.random()` as a key when the list can change. Use an id that belongs to the item.

**Simple lesson:** give every item a unique, stable `:key`, like its id.

## Repeating Groups and Components

- `<template v-for>` repeats several elements together, without a wrapper tag.
- You can use `v-for` on your own components, too.
- Pass each item in with a prop. You will learn props in **Props and Events**.

const dishes = [
    { id: 1, name: "Dosa", note: "Crispy" },
    { id: 2, name: "Idli", note: "Soft" },
];
</script>

<template>
    <dl>
      <template v-for="dish in dishes" :key="dish.id">
        <dt>{{ dish.name }}</dt>
        <dd>{{ dish.note }}</dd>
      </template>
    </dl>
</template>
`,
    "DishRow.vue": `<script setup lang="ts">
defineProps({ name: String });
</script>

<template>
    <p class="row">⭐ {{ name }}</p>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

**Simple lesson:** `<template v-for>` repeats groups. Components can be repeated like elements.

## Filtering and Sorting

- Do not put `v-if` and `v-for` on the same element.
- `v-if` runs first, before the loop variable exists. You get an error.
- Instead, make a filtered list with `computed`, then loop over it.
- To sort, copy the array first with `[...list]`. `sort()` changes the array it is called on.

const dishes = ref([
    { id: 1, name: "Chicken Biryani", price: 220, veg: false },
    { id: 2, name: "Masala Dosa", price: 90, veg: true },
    { id: 3, name: "Paneer Tikka", price: 180, veg: true },
]);
const vegOnly = ref<boolean>(false);
const cheapFirst = ref<boolean>(false);

const shown = computed(() => {
    let list = vegOnly.value ? dishes.value.filter((d) => d.veg) : dishes.value;
    if (cheapFirst.value) list = [...list].sort((a, b) => a.price - b.price);
    return list;
});
</script>

<template>
    <label><input type="checkbox" @change="vegOnly = !vegOnly" /> Veg only</label>
    <label><input type="checkbox" @change="cheapFirst = !cheapFirst" /> Cheapest first</label>
    <ul>
      <li v-for="d in shown" :key="d.id">{{ d.name }} ₹{{ d.price }}</li>
    </ul>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

To hide one item inside a loop, put `v-if` on an inner element or on a `<template v-for>` wrapper's child. But a computed filtered list is clearer and faster.

**Simple lesson:** filter and sort in a `computed`. Loop over the result.

## Array Change Detection

- **Mutating methods** change the array itself: `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse`.
- Vue sees all of them and updates the list.
- **Non-mutating methods** return a new array: `filter`, `map`, `concat`, `slice`.
- They do not change the original. Assign the result to update the list.

const orders = ref<number[]>([501, 502, 503, 504]);

function wrong(): void {
    orders.value.filter((n) => n % 2 === 0);
    console.log("filter alone changed nothing:", orders.value.length, "orders");
}

function right(): void {
    orders.value = orders.value.filter((n) => n % 2 === 0);
}
</script>

<template>
    <p>Orders: {{ orders.join(", ") }}</p>
    <button @click="wrong">Keep even (forgot to assign)</button>
    <button @click="right">Keep even (assigned)</button>
    <button @click="orders.push(505)">Push 505</button>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

Vue reuses the rows that match by key when you replace an array. Replacing a list with a filtered copy is cheap.

**Simple lesson:** mutating methods update the list. For `filter` and `map`, assign the new array.

## TL;DR

- **`v-for="item in items"`** = repeats an element for each item.
- **`(item, index)`** = the item and its position, from 0.
- **`(value, key, index) in obj`** = loops over an object.
- **`n in 5`** = repeats 5 times. `n` goes from 1 to 5.
- **`:key`** = a unique, stable name per item. Use an id.
- **Index as key** = breaks when items are added, removed or sorted.
- **`<template v-for>`** = repeats a group without a wrapper.
- **`v-if` + `v-for`** = never on one element. Filter with `computed`.
- **`filter` / `map`** = return a new array. Assign it.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| No `:key` | Vue matches items by itself | Add `:key="item.id"` | Warnings, and state jumps between rows |
| Index as key | The position is a fine name | Use an id from the data | Typed text and focus move to the wrong row |
| `v-if` with `v-for` on one tag | Vue filters while looping | Loop over a computed filtered list | An error: the loop variable is not defined |
| Range starting at 0 | `n in 3` gives 0, 1, 2 | It gives 1, 2, 3 | Off-by-one labels |
| `list.value.filter(...)` alone | filter changes the array | Assign: `list.value = list.value.filter(...)` | Nothing changes on screen |
| Sorting the original in a computed | `sort()` makes a copy | Use `[...list].sort()` | Your source data changes order |

</div>

## Check Yourself

## Exercises

## What to Remember

- `v-for` works on arrays, objects and number ranges (from 1).
- Every item needs a unique, stable `:key`, usually its id.
- Index keys move typed text and focus to the wrong row.
- Filter and sort in a `computed`, never with `v-if` on the same tag.
- `push`, `splice` and `sort` update the list. Assign the result of `filter` and `map`.

Next: **Events and Forms**
