Components: The Building Blocks of Vue Applications
Components are the fundamental building blocks of Vue applications. They encapsulate reusable code, maintain their own state, and interact with one another to build complex UIs.
Think of components like LEGO blocks – each has a specific purpose and can be combined in various ways to create something larger. Just as you might have specialized LEGO pieces for different functions (wheels, windows, etc.), in Vue, you create specialized components for different parts of your UI (buttons, forms, navigation bars, etc.).
Benefits of Component-Based Architecture
- Reusability: Write once, use many times throughout your application
- Maintainability: When code is broken into manageable pieces, it's easier to update and debug
- Collaboration: Different team members can work on different components simultaneously
- Testing: Components can be tested in isolation
- Readability: Components have descriptive names that reflect their purpose
Component Registration
Before using a component, you need to register it. Vue provides two methods for component registration: global and local.
Global Registration
Globally registered components can be used anywhere in your application without importing them individually:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import BaseButton from './components/BaseButton.vue'
import BaseInput from './components/BaseInput.vue'
const app = createApp(App)
// Register components globally
app.component('BaseButton', BaseButton)
app.component('BaseInput', BaseInput)
app.mount('#app')
While convenient, global registration has drawbacks:
- All components are included in the bundle, even if unused
- Dependencies are less explicit, making code harder to maintain
Global registration is best reserved for truly shared base components used throughout your application.
Local Registration
Locally registered components must be imported and registered in each component that uses them:
<template>
<div>
<h1>Product Page</h1>
<ProductDetail :product="product" />
<RelatedProducts :category="product.category" />
</div>
</template>
<script>
import ProductDetail from './components/ProductDetail.vue'
import RelatedProducts from './components/RelatedProducts.vue'
export default {
name: 'ProductPage',
components: {
ProductDetail,
RelatedProducts
},
data() {
return {
product: { /* ... */ }
}
}
}
</script>
With the Composition API and <script setup>, the process is even simpler:
<template>
<div>
<h1>Product Page</h1>
<ProductDetail :product="product" />
<RelatedProducts :category="product.category" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import ProductDetail from './components/ProductDetail.vue'
import RelatedProducts from './components/RelatedProducts.vue'
// Components imported in <script setup> are automatically registered
const product = ref({ /* ... */ })
</script>
Local registration is the recommended approach for most components because it:
- Makes dependencies explicit
- Enables better tree-shaking for smaller bundles
- Improves code readability by showing direct relationships
Component Communication with Props
Props are custom attributes you can register on a component, allowing you to pass data from parent components to child components.
Real-world analogy: Props are like arguments passed to a function. Just as you provide specific inputs to a function to get specific outputs, you pass props to a component to customize its behavior and appearance.
Defining Props (Options API)
<template>
<div class="product-card">
<img :src="image" :alt="name" />
<h3>{{ name }}</h3>
<p class="price">{{ formatPrice(price) }}</p>
<p v-if="inStock">In Stock</p>
<p v-else class="out-of-stock">Out of Stock</p>
<button :disabled="!inStock">Add to Cart</button>
</div>
</template>
<script>
export default {
name: 'ProductCard',
// Define props the component accepts
props: {
name: {
type: String,
required: true
},
price: {
type: Number,
required: true
},
image: {
type: String,
default: '/images/default-product.jpg'
},
inStock: {
type: Boolean,
default: true
}
},
methods: {
formatPrice(value) {
return `${value.toFixed(2)}`;
}
}
}
</script>
Defining Props (Composition API)
<template>
<div class="product-card">
<img :src="image" :alt="name" />
<h3>{{ name }}</h3>
<p class="price">{{ formatPrice(price) }}</p>
<p v-if="inStock">In Stock</p>
<p v-else class="out-of-stock">Out of Stock</p>
<button :disabled="!inStock">Add to Cart</button>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
// Define props with the defineProps compiler macro
const props = defineProps({
name: {
type: String,
required: true
},
price: {
type: Number,
required: true
},
image: {
type: String,
default: '/images/default-product.jpg'
},
inStock: {
type: Boolean,
default: true
}
});
function formatPrice(value) {
return `${value.toFixed(2)}`;
}
</script>
Using Components with Props
<template>
<div class="products-list">
<h2>Featured Products</h2>
<div class="products-grid">
<ProductCard
v-for="product in products"
:key="product.id"
:name="product.name"
:price="product.price"
:image="product.image"
:in-stock="product.inStock"
/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import ProductCard from './ProductCard.vue';
const products = ref([
{
id: 1,
name: 'Wireless Headphones',
price: 99.99,
image: '/images/headphones.jpg',
inStock: true
},
{
id: 2,
name: 'Smartphone',
price: 699.99,
image: '/images/smartphone.jpg',
inStock: false
},
// More products...
]);
</script>
Note how prop names use camelCase in JavaScript (e.g., inStock) but kebab-case in HTML attributes (e.g., in-stock). This is a Vue convention for better compatibility with HTML standards.
Prop Validation
Vue provides a robust prop validation system that helps catch errors and document component usage:
Type Validation
You can specify the expected data type(s) for a prop:
props: {
// Basic type check (`null` and `undefined` match any type)
propA: Number,
// Multiple possible types
propB: [String, Number],
// Required string
propC: {
type: String,
required: true
},
// Number with default value
propD: {
type: Number,
default: 100
}
}
Vue supports validation for all JavaScript primitive types:
StringNumberBooleanArrayObjectFunctionSymbol- Custom classes/constructors
Default Values
For object or array defaults, you must use a factory function to return a fresh copy each time:
props: {
// Simple default value
size: {
type: String,
default: 'medium'
},
// Object default (using factory function)
user: {
type: Object,
default() {
return { name: 'Guest' }
}
},
// Array default (using factory function)
items: {
type: Array,
default() {
return []
}
}
}
Custom Validators
You can define custom validation functions for more complex logic:
props: {
// Custom validator
status: {
type: String,
validator(value) {
// The value must match one of these strings
return ['draft', 'published', 'archived'].includes(value)
}
},
// Range validation
rating: {
type: Number,
validator(value) {
return value >= 1 && value <= 5
}
}
}
Real-world analogy: Prop validation is like a security check at an entrance. Just as security ensures only authorized people with proper credentials enter a building, prop validation ensures that components receive the correct data types and values they need to function properly.
One-Way Data Flow
Props in Vue follow a one-way data flow pattern: parent to child. This means:
- Parent component can pass data down to child components
- When parent data updates, it flows down to children
- Child components cannot directly modify the props they receive
- Attempting to mutate props directly will cause warnings in development mode
This one-way flow helps maintain a predictable data flow and prevents unexpected side effects.
What If a Child Needs to Modify Props?
There are two primary patterns for cases where a child component needs to influence the parent:
1. Using props as initial values for local data:
<script>
export default {
props: {
initialCounter: {
type: Number,
default: 0
}
},
data() {
return {
// Using the prop as initial value for local state
counter: this.initialCounter
}
}
}
</script>
2. Using computed properties with getters and setters:
<script>
export default {
props: {
modelValue: {
type: String,
required: true
}
},
computed: {
// Computed property with getter and setter
inputValue: {
get() {
return this.modelValue
},
set(value) {
// Emit event instead of mutating prop directly
this.$emit('update:modelValue', value)
}
}
}
}
</script>
Real-world analogy: This is like a restaurant. The chef (parent component) prepares the dishes and sends them out with waiters. Customers (child components) can enjoy the food as served, but they cannot go into the kitchen to change the recipe. If they want modifications, they must send requests back through the waiter (events).
Props vs. Data
Understanding the distinction between props and component data is essential:
| Props | Data |
|---|---|
| Passed from parent component | Defined within the component |
| Should not be mutated | Can be freely modified |
| Update when parent changes them | Updates trigger component re-renders |
| Define the component's API | Define the component's internal state |
Example: Props and Data Working Together
<template>
<div class="expandable-text">
<p>
{{ isExpanded ? fullText : truncatedText }}
</p>
<button v-if="needsExpansion" @click="toggleExpand">
{{ isExpanded ? 'Read Less' : 'Read More' }}
</button>
</div>
</template>
<script>
export default {
name: 'ExpandableText',
props: {
text: {
type: String,
required: true
},
maxLength: {
type: Number,
default: 100
}
},
data() {
return {
isExpanded: false
}
},
computed: {
needsExpansion() {
return this.text.length > this.maxLength;
},
truncatedText() {
if (this.text.length <= this.maxLength) {
return this.text;
}
return this.text.slice(0, this.maxLength) + '...';
},
fullText() {
return this.text;
}
},
methods: {
toggleExpand() {
this.isExpanded = !this.isExpanded;
}
}
}
</script>
In this example:
textandmaxLengthare props coming from the parentisExpandedis internal data managed by the component itself- The component uses both to create a read-more/read-less behavior
Dynamic Props
Props aren't limited to static values; they can be dynamically bound to data in the parent component:
Binding to Variables
<template>
<div>
<h1>Product Configurator</h1>
<div class="controls">
<label>
Product Title:
<input v-model="productTitle" />
</label>
<label>
Price:
<input type="number" v-model.number="productPrice" />
</label>
<label>
<input type="checkbox" v-model="productAvailable" />
Available
</label>
</div>
<!-- Dynamic props -->
<ProductDisplay
:title="productTitle"
:price="productPrice"
:available="productAvailable"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import ProductDisplay from './ProductDisplay.vue';
const productTitle = ref('Awesome Product');
const productPrice = ref(49.99);
const productAvailable = ref(true);
</script>
Binding Multiple Props Using v-bind Object
You can pass all properties of an object as props using v-bind without an argument:
<template>
<div>
<UserProfile v-bind="user" />
<!-- Equivalent to: -->
<UserProfile
:name="user.name"
:email="user.email"
:role="user.role"
:verified="user.verified"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import UserProfile from './UserProfile.vue';
const user = ref({
name: 'Jane Doe',
email: 'jane@example.com',
role: 'Admin',
verified: true
});
</script>
This is especially useful when passing many props or when props might change dynamically based on API responses.
Component Organization Patterns
As applications grow, organizing components effectively becomes crucial. Here are some common patterns:
Base Components
Base components are highly reusable components that are used throughout your application. They typically implement basic UI elements with enhanced functionality:
components/
├── base/
│ ├── BaseButton.vue
│ ├── BaseInput.vue
│ ├── BaseCheckbox.vue
│ ├── BaseSelect.vue
│ └── BaseCard.vue
Example of a BaseButton component:
<template>
<button
:class="[
'base-button',
`base-button--${size}`,
`base-button--${variant}`,
{ 'base-button--loading': loading }
]"
:disabled="disabled || loading"
:type="type"
@click="onClick"
>
<span v-if="loading" class="spinner"></span>
<slot>Button</slot>
</button>
</template>
<script>
export default {
name: 'BaseButton',
props: {
variant: {
type: String,
default: 'primary',
validator: value => ['primary', 'secondary', 'danger', 'text'].includes(value)
},
size: {
type: String,
default: 'medium',
validator: value => ['small', 'medium', 'large'].includes(value)
},
type: {
type: String,
default: 'button',
validator: value => ['button', 'submit', 'reset'].includes(value)
},
loading: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
},
methods: {
onClick(event) {
this.$emit('click', event);
}
}
}
</script>
Feature-Based Organization
Group components by feature or domain rather than by type:
src/
├── components/
│ ├── base/ # Base UI components
│ ├── product/ # Product-related components
│ │ ├── ProductCard.vue
│ │ ├── ProductDetails.vue
│ │ ├── ProductReviews.vue
│ │ └── ProductFilter.vue
│ ├── checkout/ # Checkout-related components
│ │ ├── CartSummary.vue
│ │ ├── PaymentForm.vue
│ │ └── ShippingForm.vue
│ └── user/ # User-related components
│ ├── ProfileCard.vue
│ ├── LoginForm.vue
│ └── RegisterForm.vue
This organization makes it easier to locate components when working on a specific feature and keeps related components together.
Composing Components with Slots
Slots allow you to create flexible component templates that can be filled with different content:
<!-- CardComponent.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">
<!-- Default content if no header slot is provided -->
Default Header
</slot>
</div>
<div class="card-body">
<slot>
<!-- Default content if no default slot is provided -->
Default content
</slot>
</div>
<div class="card-footer">
<slot name="footer">
<!-- Default content if no footer slot is provided -->
Default Footer
</slot>
</div>
</div>
</template>
<!-- Usage -->
<CardComponent>
<template #header>
<h2>Custom Header</h2>
</template>
<p>This is the main content of the card.</p>
<template #footer>
<button>Action Button</button>
</template>
</CardComponent>
We'll dive deeper into slots in future lectures, but they're an essential part of creating flexible, composable components.
Practice Activity
Building a Component Library
Create a small component library with the following components:
- Create a
BaseButtoncomponent with props for:- variant (primary, secondary, danger)
- size (small, medium, large)
- disabled state
- loading state
- Create a
BaseCardcomponent with:- slots for header, body (default), and footer
- props for optional border, shadow, and padding settings
- Create a
UserProfilecomponent that:- accepts user data as props (name, email, avatar, role)
- uses your BaseCard component to display the user info
- validates the user props appropriately
- Create an
Appcomponent that:- displays multiple UserProfile components with different data
- includes a form to edit a selected user's data
Start with this template for the BaseButton component:
<template>
<button
:class="[
'base-button',
`base-button--${variant}`,
`base-button--${size}`,
{ 'base-button--disabled': disabled },
{ 'base-button--loading': loading }
]"
:disabled="disabled || loading"
@click="$emit('click')"
>
<span v-if="loading" class="spinner"></span>
<slot>Button</slot>
</button>
</template>
<script>
// TODO: Implement component with appropriate props
</script>
<style scoped>
.base-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s, opacity 0.2s;
}
.base-button--primary {
background-color: #42b883;
color: white;
}
.base-button--secondary {
background-color: #34495e;
color: white;
}
.base-button--danger {
background-color: #e74c3c;
color: white;
}
.base-button--small {
padding: 6px 12px;
font-size: 0.875rem;
}
.base-button--medium {
padding: 8px 16px;
font-size: 1rem;
}
.base-button--large {
padding: 10px 20px;
font-size: 1.125rem;
}
.base-button--disabled,
.base-button--loading {
opacity: 0.7;
cursor: not-allowed;
}
.spinner {
display: inline-block;
width: 1em;
height: 1em;
margin-right: 0.5rem;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
Key Takeaways
- Components are the building blocks of Vue applications, enabling reusability and maintainability
- Components can be registered globally or locally, with local registration being preferred for most use cases
- Props allow parent components to pass data to child components
- Vue enforces a one-way data flow from parent to child, enhancing predictability
- Props can be validated with type checking, custom validators, and default values
- Component organization patterns like base components and feature-based organization help maintain larger applications
- Understanding the distinction between props and component data is essential for effective component design