7.3: Components & Props
Learn how to build reusable, modular components in Vue.js to create maintainable applications. Understand component structure, props, events, and how to compose complex interfaces from simple building blocks.
1. Understanding Component Architecture
What Are Components?
Components are reusable, self-contained pieces of UI. Think of them like LEGO blocks that you can combine to build complex applications.
Without components:
<!-- Repetitive, hard to maintain -->
<template>
<div class="user-card">
<img src="user1.jpg" alt="Alice">
<h3>Alice</h3>
<p>Developer</p>
</div>
<div class="user-card">
<img src="user2.jpg" alt="Bob">
<h3>Bob</h3>
<p>Designer</p>
</div>
<div class="user-card">
<img src="user3.jpg" alt="Charlie">
<h3>Charlie</h3>
<p>Manager</p>
</div>
</template>
With components:
<!-- Reusable, maintainable -->
<template>
<UserCard name="Alice" role="Developer" avatar="user1.jpg" />
<UserCard name="Bob" role="Designer" avatar="user2.jpg" />
<UserCard name="Charlie" role="Manager" avatar="user3.jpg" />
</template>
Benefits of Components
- Reusability: Write once, use everywhere
- Maintainability: Update in one place, changes everywhere
- Encapsulation: Styles and logic contained
- Testability: Test components in isolation
- Collaboration: Different people work on different components
- Organization: Clear structure, easy to navigate
2. Creating Components
Single File Components (.vue)
Vue components use .vue files with three sections:
<!-- components/UserCard.vue -->
<script setup>
// JavaScript logic
import { ref } from 'vue'
const isExpanded = ref(false)
</script>
<template>
<!-- HTML template -->
<div class="user-card">
<h3>User Card</h3>
</div>
</template>
<style scoped>
/* CSS styles (scoped to this component) */
.user-card {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
}
</style>
Component Naming Conventions
PascalCase for component files and usage:
components/
UserCard.vue ✓
ProductList.vue ✓
ShoppingCart.vue ✓
userCard.vue ✗ (not PascalCase)
product-list.vue ✗ (kebab-case for files)
Usage in templates:
<template>
<!-- PascalCase (preferred) -->
<UserCard />
<ProductList />
<!-- kebab-case (also works) -->
<user-card />
<product-list />
</template>
Importing and Using Components
In Vite projects, components are auto-imported from components/ folder:
<!-- No import needed! -->
<template>
<UserCard />
</template>
Manual import (if needed):
<script setup>
import UserCard from './components/UserCard.vue'
import ProductList from '@/components/ProductList.vue' // @ = src/
</script>
<template>
<UserCard />
<ProductList />
</template>
3. Props - Passing Data to Components
Basic Props
Props let parent components pass data to children:
<!-- components/Greeting.vue -->
<script setup>
// Declare props
const props = defineProps({
name: String,
age: Number
})
</script>
<template>
<div class="greeting">
<h2>Hello, {{ name }}!</h2>
<p>You are {{ age }} years old.</p>
</div>
</template>
Using the component:
<!-- Parent component -->
<script setup>
import { ref } from 'vue'
const userName = ref('Alice')
const userAge = ref(25)
</script>
<template>
<Greeting name="Bob" :age="30" />
<Greeting :name="userName" :age="userAge" />
</template>
Key points:
- Use
:for dynamic (reactive) values - No
:for static strings - Props are read-only in child components
Props with Types
Define prop types for validation:
<script setup>
const props = defineProps({
// Type only
title: String,
count: Number,
isActive: Boolean,
tags: Array,
user: Object,
callback: Function,
// Multiple types
id: [String, Number],
// Required prop
name: {
type: String,
required: true
},
// Default value
age: {
type: Number,
default: 18
},
// Default for object/array (use factory function)
profile: {
type: Object,
default: () => ({
city: 'Beijing',
country: 'China'
})
},
tags: {
type: Array,
default: () => []
}
})
</script>
Prop Validation
Add custom validators:
<script setup>
const props = defineProps({
// Custom validator
status: {
type: String,
validator: (value) => {
return ['active', 'inactive', 'pending'].includes(value)
}
},
// Age must be positive
age: {
type: Number,
validator: (value) => value > 0
},
// Email format
email: {
type: String,
validator: (value) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
}
})
</script>
Props Best Practices
<script setup>
// ✓ Good: Specific types, required, defaults
const props = defineProps({
userId: {
type: Number,
required: true
},
userName: {
type: String,
default: 'Guest'
},
isAdmin: {
type: Boolean,
default: false
}
})
// ✗ Bad: No types, no validation
const props = defineProps(['userId', 'userName', 'isAdmin'])
</script>
Don't Mutate Props
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
initialCount: Number
})
// ✗ Wrong: Don't mutate props
// props.initialCount++ // Error!
// ✓ Correct: Copy to local state
const count = ref(props.initialCount)
count.value++ // OK
// ✓ Or use computed for transformation
const doubleCount = computed(() => props.initialCount * 2)
</script>
4. Events - Child to Parent Communication
Emitting Events
Children communicate with parents via events:
<!-- components/Counter.vue -->
<script setup>
import { ref } from 'vue'
// Declare emitted events
const emit = defineEmits(['increment', 'decrement', 'reset'])
const count = ref(0)
function handleIncrement() {
count.value++
emit('increment', count.value)
}
function handleDecrement() {
count.value--
emit('decrement', count.value)
}
function handleReset() {
count.value = 0
emit('reset')
}
</script>
<template>
<div class="counter">
<p>Count: {{ count }}</p>
<button @click="handleIncrement">+</button>
<button @click="handleDecrement">-</button>
<button @click="handleReset">Reset</button>
</div>
</template>
Parent listening to events:
<script setup>
function onIncrement(newValue) {
console.log('Incremented to:', newValue)
}
function onDecrement(newValue) {
console.log('Decremented to:', newValue)
}
function onReset() {
console.log('Counter reset')
}
</script>
<template>
<Counter
@increment="onIncrement"
@decrement="onDecrement"
@reset="onReset"
/>
</template>
Event Validation
<script setup>
// Validate emitted events
const emit = defineEmits({
// No validation
click: null,
// Validate payload
submit: (payload) => {
if (!payload.email) {
console.warn('Email is required')
return false
}
return true
},
// Multiple arguments
update: (id, value) => {
return typeof id === 'number' && typeof value === 'string'
}
})
</script>
v-model with Components
v-model is syntactic sugar for prop + event:
<!-- components/CustomInput.vue -->
<script setup>
const props = defineProps({
modelValue: String
})
const emit = defineEmits(['update:modelValue'])
function handleInput(event) {
emit('update:modelValue', event.target.value)
}
</script>
<template>
<input
:value="modelValue"
@input="handleInput"
class="custom-input"
>
</template>
Using v-model:
<script setup>
import { ref } from 'vue'
const text = ref('')
</script>
<template>
<CustomInput v-model="text" />
<p>You typed: {{ text }}</p>
</template>
Multiple v-models:
<!-- components/UserForm.vue -->
<script setup>
defineProps({
firstName: String,
lastName: String
})
const emit = defineEmits([
'update:firstName',
'update:lastName'
])
</script>
<template>
<input
:value="firstName"
@input="$emit('update:firstName', $event.target.value)"
placeholder="First name"
>
<input
:value="lastName"
@input="$emit('update:lastName', $event.target.value)"
placeholder="Last name"
>
</template>
Usage:
<script setup>
import { ref } from 'vue'
const first = ref('')
const last = ref('')
</script>
<template>
<UserForm v-model:first-name="first" v-model:last-name="last" />
<p>{{ first }} {{ last }}</p>
</template>
5. Slots - Flexible Component Composition
Default Slots
Slots let you pass content to components:
<!-- components/Card.vue -->
<template>
<div class="card">
<slot>
<!-- Fallback content if nothing provided -->
Default card content
</slot>
</div>
</template>
<style scoped>
.card {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
}
</style>
Using slots:
<template>
<Card>
<h3>Custom Title</h3>
<p>Custom content goes here</p>
</Card>
<Card>
<img src="photo.jpg" alt="Photo">
<p>Image card</p>
</Card>
<Card>
<!-- No content, shows fallback -->
</Card>
</template>
Named Slots
Multiple slots in one component:
<!-- components/Layout.vue -->
<template>
<div class="layout">
<header>
<slot name="header">Default Header</slot>
</header>
<main>
<slot>Default Main Content</slot>
</main>
<footer>
<slot name="footer">Default Footer</slot>
</footer>
</div>
</template>
Using named slots:
<template>
<Layout>
<template #header>
<h1>My App</h1>
<nav>Navigation</nav>
</template>
<p>Main content goes in default slot</p>
<template #footer>
<p>© 2025 My Company</p>
</template>
</Layout>
</template>
Scoped Slots
Pass data from child to slot content:
<!-- components/TodoList.vue -->
<script setup>
import { ref } from 'vue'
const todos = ref([
{ id: 1, text: 'Learn Vue', done: false },
{ id: 2, text: 'Build app', done: true }
])
</script>
<template>
<ul>
<li v-for="todo in todos" :key="todo.id">
<slot :todo="todo" :index="index">
<!-- Fallback -->
{{ todo.text }}
</slot>
</li>
</ul>
</template>
Using scoped slots:
<template>
<TodoList>
<template #default="{ todo }">
<span :class="{ done: todo.done }">
{{ todo.text }}
</span>
</template>
</TodoList>
</template>
6. Component Organization
File Structure
src/
├── components/
│ ├── common/ # Shared components
│ │ ├── Button.vue
│ │ ├── Card.vue
│ │ └── Modal.vue
│ ├── layout/ # Layout components
│ │ ├── Header.vue
│ │ ├── Footer.vue
│ │ └── Sidebar.vue
│ ├── features/ # Feature-specific
│ │ ├── UserProfile/
│ │ │ ├── UserAvatar.vue
│ │ │ ├── UserBio.vue
│ │ │ └── UserCard.vue
│ │ └── TodoList/
│ │ ├── TodoItem.vue
│ │ ├── TodoFilter.vue
│ │ └── TodoList.vue
│ └── icons/ # Icon components
│ ├── IconHome.vue
│ └── IconUser.vue
├── views/ # Page components
│ ├── Home.vue
│ ├── About.vue
│ └── Profile.vue
└── App.vue
Component Naming
Base Components (used everywhere):
BaseButton.vue
BaseInput.vue
BaseCard.vue
Single-Instance Components (used once per page):
TheHeader.vue
TheSidebar.vue
TheFooter.vue
Tightly Coupled Components (parent-child):
TodoList.vue
TodoListItem.vue
TodoListFilter.vue
UserProfile.vue
UserProfileAvatar.vue
UserProfileBio.vue
7. Real-World Component Example
Building a Product Card Component
<!-- components/ProductCard.vue -->
<script setup>
const props = defineProps({
product: {
type: Object,
required: true,
validator: (value) => {
return value.id && value.name && value.price
}
},
showDiscount: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['add-to-cart', 'view-details'])
function addToCart() {
emit('add-to-cart', props.product)
}
function viewDetails() {
emit('view-details', props.product.id)
}
</script>
<template>
<div class="product-card">
<img
:src="product.image"
:alt="product.name"
class="product-image"
>
<div class="product-info">
<h3>{{ product.name }}</h3>
<div class="price">
<span class="current-price">${{ product.price }}</span>
<span
v-if="showDiscount && product.originalPrice"
class="original-price"
>
${{ product.originalPrice }}
</span>
</div>
<p class="description">{{ product.description }}</p>
<div class="actions">
<button @click="addToCart" class="btn-primary">
Add to Cart
</button>
<button @click="viewDetails" class="btn-secondary">
Details
</button>
</div>
</div>
</div>
</template>
<style scoped>
.product-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.product-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.product-info {
padding: 1rem;
}
.product-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.price {
margin-bottom: 0.5rem;
}
.current-price {
font-size: 1.5rem;
font-weight: bold;
color: #42b983;
}
.original-price {
margin-left: 0.5rem;
text-decoration: line-through;
color: #999;
}
.description {
color: #666;
margin-bottom: 1rem;
line-height: 1.5;
}
.actions {
display: flex;
gap: 0.5rem;
}
button {
flex: 1;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: opacity 0.2s;
}
button:hover {
opacity: 0.9;
}
.btn-primary {
background: #42b983;
color: white;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
</style>
Using the ProductCard:
<script setup>
import { ref } from 'vue'
const products = ref([
{
id: 1,
name: 'Wireless Headphones',
price: 79.99,
originalPrice: 99.99,
image: 'headphones.jpg',
description: 'High-quality wireless headphones with noise cancellation'
},
{
id: 2,
name: 'Smart Watch',
price: 199.99,
image: 'watch.jpg',
description: 'Fitness tracker with heart rate monitor'
}
])
function handleAddToCart(product) {
console.log('Added to cart:', product)
// Add to cart logic
}
function handleViewDetails(productId) {
console.log('View details:', productId)
// Navigate to details page
}
</script>
<template>
<div class="product-grid">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
:show-discount="true"
@add-to-cart="handleAddToCart"
@view-details="handleViewDetails"
/>
</div>
</template>
<style scoped>
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
padding: 1.5rem;
}
</style>
8. Practical Exercises
Exercise 5.3.1: Alert Component
Create a reusable Alert component:
- Props: type ('info', 'success', 'warning', 'error'), message, dismissible
- Different colors based on type
- Optional close button if dismissible
- Emit 'close' event when dismissed
Exercise 5.3.2: Modal Component
Build a Modal component:
- Props: title, visible (boolean)
- Named slots: header, default (body), footer
- Emit 'close' event
- Close on backdrop click
- Close on Escape key
Exercise 5.3.3: Data Table Component
Create a DataTable component:
- Props: columns (array), data (array)
- Display data in table
- Emit 'row-click' event
- Scoped slot for custom cell rendering
- Add sorting functionality
Exercise 5.3.4: Form Components
Build form components:
- TextInput component (label, validation)
- Checkbox component
- Select component
- All use v-model
- Show validation errors
9. Knowledge Check
Question 1: What are props and why are they read-only?
Show answer
Props are how parent components pass data to child components. They're read-only to maintain one-way data flow and prevent child components from unexpectedly modifying parent state.Question 2: How do child components communicate with parents?
Show answer
Child components emit events using `defineEmits()` and `emit()`. Parents listen to these events with `@event-name` in the template.Question 3: What's the difference between default slots and named slots?
Show answer
Default slots have no name and receive all content not assigned to a named slot. Named slots use `name` attribute and are targeted with `#slot-name` in parent. You can have one default slot and multiple named slots.Question 4: When should you use scoped slots?
Show answer
Use scoped slots when the child component has data that the parent needs to render. The child passes data to the slot, and the parent decides how to display it.Question 5: How does v-model work with components?
Show answer
`v-model` is syntactic sugar for `:modelValue` prop and `@update:modelValue` event. The component receives the value via prop and emits updates via event.10. Key Takeaways
- Components are reusable, self-contained UI pieces
- Props pass data from parent to child (one-way, read-only)
- Events communicate from child to parent (emit)
- Slots allow flexible content composition
- Named slots enable multiple content areas
- Scoped slots pass child data to parent templates
v-modelworks with components via modelValue prop + update event- Component naming: PascalCase for files and usage
- Organization: Group related components in folders
- Validation: Always validate props and events
11. Further Resources
Official Documentation:
Component Libraries for Inspiration:
Next Steps
Fantastic work! You now understand how to build reusable components with props, events, and slots.
In Lesson 5.4: Events & Form Handling, you'll dive deeper into event handling, form validation, and building complex interactive components.