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

  1. Reusability: Write once, use everywhere
  2. Maintainability: Update in one place, changes everywhere
  3. Encapsulation: Styles and logic contained
  4. Testability: Test components in isolation
  5. Collaboration: Different people work on different components
  6. 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>&copy; 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:

  1. Props: type ('info', 'success', 'warning', 'error'), message, dismissible
  2. Different colors based on type
  3. Optional close button if dismissible
  4. Emit 'close' event when dismissed

Exercise 5.3.2: Modal Component

Build a Modal component:

  1. Props: title, visible (boolean)
  2. Named slots: header, default (body), footer
  3. Emit 'close' event
  4. Close on backdrop click
  5. Close on Escape key

Exercise 5.3.3: Data Table Component

Create a DataTable component:

  1. Props: columns (array), data (array)
  2. Display data in table
  3. Emit 'row-click' event
  4. Scoped slot for custom cell rendering
  5. Add sorting functionality

Exercise 5.3.4: Form Components

Build form components:

  1. TextInput component (label, validation)
  2. Checkbox component
  3. Select component
  4. All use v-model
  5. 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-model works 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.