Chapter 23: Vue v-for Components
v-for with components in Vue 3.
This is one of the most common and most powerful patterns you’ll use in real Vue applications. Almost every real app has lists: todo items, products in a shop, users in a table, messages in chat, cards in a dashboard, search results, blog posts… and in 99% of cases you will not repeat the same HTML structure 20 times manually — you will create one reusable component and then loop it with v-for.
So “Vue v-for Components” really means:
Using v-for to render multiple instances of the same component, passing different data to each instance via props, and usually listening to events that each child component can emit back to the parent.
Let’s go through it step by step with a realistic, modern example.
Classic Pattern – Todo List with TodoItem Component
This is the pattern you’ll see in almost every intermediate/advanced Vue tutorial and real project.
1. Child Component: TodoItem.vue
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
<!-- src/components/TodoItem.vue --> <template> <div class="todo-item" :class="{ completed: isDone }"> <input type="checkbox" :checked="isDone" @change="emitToggle" /> <span class="todo-text">{{ text }}</span> <button class="delete-btn" @click="emitDelete"> × </button> </div> </template> <script setup lang="ts"> defineProps<{ id: number text: string done: boolean }>() const emit = defineEmits<{ (e: 'toggle', id: number): void (e: 'delete', id: number): void }>() const isDone = computed(() => props.done) function emitToggle() { emit('toggle', props.id) } function emitDelete() { emit('delete', props.id) } </script> <style scoped> .todo-item { display: flex; align-items: center; padding: 0.9rem 1.2rem; background: white; border-radius: 8px; margin-bottom: 0.6rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: all 0.2s; } .todo-item:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); } .completed .todo-text { text-decoration: line-through; color: #9ca3af; opacity: 0.75; } .todo-text { flex: 1; margin: 0 1rem; font-size: 1.05rem; } .delete-btn { background: none; border: none; color: #ef4444; font-size: 1.5rem; cursor: pointer; padding: 0 0.6rem; opacity: 0.6; } .delete-btn:hover { opacity: 1; } </style> |
2. Parent Component – Using v-for to render many TodoItem
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
<!-- src/views/TodoList.vue or HomeView.vue --> <template> <div class="todo-app"> <h1>My Todos</h1> <div class="input-row"> <input v-model.trim="newTask" placeholder="What needs to be done?" @keyup.enter="addTask" /> <button @click="addTask">Add</button> </div> <div v-if="todos.length === 0" class="empty-state"> No tasks yet — add your first one! </div> <!-- ★ This is the key part ★ --> <TodoItem v-for="todo in filteredTodos" :key="todo.id" :id="todo.id" :text="todo.text" :done="todo.done" @toggle="toggleTodo" @delete="deleteTodo" /> <div class="stats"> <p>{{ remaining }} of {{ todos.length }} remaining</p> <div class="filters"> <button v-for="f in filters" :key="f.value" :class="{ active: filter === f.value }" @click="filter = f.value" > {{ f.label }} </button> </div> </div> </div> </template> <script setup lang="ts"> import { ref, computed } from 'vue' import TodoItem from '@/components/TodoItem.vue' interface Todo { id: number text: string done: boolean } const newTask = ref('') const todos = ref<Todo[]>([ { id: 1, text: 'Learn v-for with components', done: true }, { id: 2, text: 'Build real todo app', done: false }, { id: 3, text: 'Style it nicely', done: false } ]) const filter = ref<'all' | 'active' | 'completed'>('all') const filteredTodos = computed(() => { if (filter.value === 'active') return todos.value.filter(t => !t.done) if (filter.value === 'completed') return todos.value.filter(t => t.done) return todos.value }) const remaining = computed(() => todos.value.filter(t => !t.done).length) const filters = [ { value: 'all', label: 'All' }, { value: 'active', label: 'Active' }, { value: 'completed',label: 'Completed' } ] function addTask() { if (!newTask.value.trim()) return todos.value.push({ id: Date.now(), text: newTask.value.trim(), done: false }) newTask.value = '' } function toggleTodo(id: number) { const todo = todos.value.find(t => t.id === id) if (todo) todo.done = !todo.done } function deleteTodo(id: number) { todos.value = todos.value.filter(t => t.id !== id) } </script> <style scoped> .todo-app { max-width: 600px; margin: 2rem auto; padding: 1rem; } .input-row { display: flex; gap: 0.8rem; margin-bottom: 1.5rem; } input { flex: 1; padding: 0.9rem; border-radius: 6px; border: 1px solid #d1d5db; } button { padding: 0.9rem 1.6rem; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer; } .empty-state { text-align: center; color: #6b7280; padding: 3rem 1rem; font-style: italic; } .stats { margin-top: 1.5rem; display: flex; justify-content: space-between; align-items: center; font-size: 0.95rem; color: #4b5563; } .filters button { margin-left: 0.8rem; padding: 0.4rem 1rem; border-radius: 6px; background: #f3f4f6; border: none; cursor: pointer; } .filters .active { background: #3b82f6; color: white; } </style> |
Why This Pattern is So Powerful (Key Lessons)
- Reusability One TodoItem component → used 1 time or 1000 times — same code.
- Single source of truth All logic & state lives in parent (todos array) → child is dumb (just renders props & emits events).
- v-for + :key is mandatory:key=”todo.id” → Vue can track which item is which when list changes (add/delete/reorder) → prevents bugs.
- Props go down, events go up (“Data down, actions up”)
- :text=”todo.text” → data flows down
- @toggle=”toggleTodo” → child tells parent “toggle this id”
- TypeScript makes it safedefineProps<{ id: number; text: string; done: boolean }> → autocompletion, no typos
Quick Cheat Sheet – v-for + Components
| Part | What you write | Purpose / Tip |
|---|---|---|
| Parent loop | <TodoItem v-for=”todo in todos” :key=”todo.id” /> | Always :key with unique stable value |
| Pass data | :id=”todo.id” :text=”todo.text” :done=”todo.done” | Use : for dynamic values (not strings) |
| Listen to child events | @toggle=”toggleTodo” @delete=”deleteTodo” | Child emits → parent handles state change |
| Child declares props | defineProps<{ id: number; text: string; done: boolean }>() | Best with TS |
| Child emits events | defineEmits<{ (e: ‘toggle’, id: number): void }>() | Type-safe events |
Your Mini Homework
- Add an edit feature:
- Add @edit emit in TodoItem (maybe double-click on text)
- In parent → when @edit → prompt for new text → update todo
- Create a ProductCard.vue component and loop 6–8 fake products
Any part still unclear? Want me to show:
- How to make TodoItem editable inline?
- v-for with <TransitionGroup> for animations?
- Passing functions as props (advanced, sometimes needed)?
- Difference between v-for on component vs on native element?
Just say — we’ll build the next step together 🚀
Happy looping components from Hyderabad! 💙
