Table of Contents
-
Understanding Vue.js Components: Core Concepts
- What Are Vue.js Components?
- Global vs. Local Components
- Component Instances
-
Creating Your First Vue.js Component
- Component Structure (Template, Script, Style)
- A Practical Example: UserCard Component
-
Component Props: Passing Data In
- Declaring Props
- Prop Validation & Type Safety
- Default Values and Required Props
-
Component Events: Emitting Data Out
- Emitting Custom Events with
$emit - Handling Events in Parent Components
- Event Modifiers
- Emitting Custom Events with
-
Component Composition: Reusability & Organization
- Slots: Content Distribution
- Dynamic Components
- Component Registration Strategies
-
- Scoped Styles
- Teleport: Rendering Outside the DOM Tree
- Provide/Inject: Deep Component Communication
-
Best Practices for Developing Custom UI Components
- Single Responsibility Principle
- Accessibility (a11y) Considerations
- Testing Components
- Documentation
1. Understanding Vue.js Components: Core Concepts
What Are Vue.js Components?
A Vue.js component is a self-contained, reusable Vue instance with predefined options (e.g., template, data, methods). Think of components as custom HTML elements that encapsulate their own logic, structure, and styling. They enable you to split complex UIs into smaller, independent pieces, making your codebase easier to debug, test, and scale.
Every Vue app is built as a tree of components, starting with a root component (typically App.vue) that contains child components, which may themselves contain more components.
Global vs. Local Components
Vue components can be registered either globally or locally, depending on their intended use:
-
Global Components: Registered once with
Vue.component()(Vue 2) orapp.component()(Vue 3) and available throughout the app. Use for frequently reused components (e.g., buttons, inputs).// Vue 3 example (in main.js) import { createApp } from 'vue'; import App from './App.vue'; import GlobalButton from './components/GlobalButton.vue'; const app = createApp(App); app.component('GlobalButton', GlobalButton); // Register globally app.mount('#app'); -
Local Components: Registered only in the scope of a parent component and unavailable elsewhere. Use for component-specific or one-off components to avoid polluting the global namespace.
// Vue 3 (in a parent component's <script setup>) import LocalCard from './components/LocalCard.vue';
Component Instances
Each component instance is isolated: data, methods, and computed properties are scoped to the instance. This isolation prevents unintended side effects between components. For example, two instances of a Counter component will each have their own count data property.
2. Creating Your First Vue.js Component
Let’s build a simple component to understand its structure. We’ll use Vue 3 with the Composition API (via <script setup>), the recommended approach for new projects due to its brevity and flexibility.
Component Structure
A Vue component typically has three sections:
- Template: The HTML structure (using Vue’s template syntax).
- Script: The logic (data, props, methods, etc.).
- Style: CSS styles (scoped or global).
A Practical Example: UserCard Component
Let’s create a UserCard component to display user information (name, avatar, role).
Step 1: Create the Component File
Create src/components/UserCard.vue with the following structure:
<!-- UserCard.vue -->
<template>
<div class="user-card">
<img :src="user.avatar" :alt="user.name" class="avatar" />
<div class="info">
<h3>{{ user.name }}</h3>
<p class="role">{{ user.role }}</p>
</div>
</div>
</template>
<script setup>
// Declare props (we'll cover props in detail later)
const props = defineProps({
user: {
type: Object,
required: true,
// Validate the structure of the user object
validator: (value) => {
return 'name' in value && 'avatar' in value && 'role' in value;
}
}
</script>
<style scoped>
.user-card {
display: flex;
align-items: center;
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
max-width: 300px;
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
margin-right: 1rem;
}
.info h3 {
margin: 0 0 0.5rem 0;
color: #333;
}
.role {
margin: 0;
color: #666;
font-size: 0.9rem;
}
</style>
Using the Component
To use UserCard, import it into a parent component (e.g., App.vue) and include it in the template:
<!-- App.vue -->
<template>
<div class="app">
<h1>Team Members</h1>
<UserCard
:user="{
name: 'Alice Smith',
avatar: 'https://i.pravatar.cc/150?img=1',
role: 'Frontend Developer'
}"
/>
<UserCard
:user="{
name: 'Bob Johnson',
avatar: 'https://i.pravatar.cc/150?img=2',
role: 'Backend Developer'
}"
/>
</div>
</template>
<script setup>
import UserCard from './components/UserCard.vue'; // Local registration
</script>
<style>
.app {
padding: 2rem;
}
</style>
Here, UserCard is registered locally (only available in App.vue). The result will be two card components displaying user data.
3. Component Props: Passing Data In
Props are custom attributes for passing data from a parent component to a child component. They are the primary way to share data downward in the component tree.
Declaring Props
In Vue 3’s <script setup>, use defineProps() to declare props. You can specify prop types, default values, and validation rules.
Basic Prop Declaration
// In UserCard.vue's <script setup>
const props = defineProps({
// Basic type check (String, Number, Boolean, Array, Object, Date, Function, Symbol)
name: String,
age: Number,
// Required prop with type
userId: {
type: String,
required: true
},
// Prop with default value
role: {
type: String,
default: 'Guest' // Default if not provided
},
// Array/Object defaults must be returned from a factory function
hobbies: {
type: Array,
default: () => ['Reading', 'Hiking'] // Factory function to avoid shared references
},
// Custom validator
email: {
type: String,
validator: (value) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); // Validate email format
}
}
});
Using Props in Templates
Props are read-only in the child component (mutating props directly is anti-pattern). Use them in the template like regular variables:
<template>
<div>
<p>Name: {{ name }}</p>
<p>Role: {{ role }}</p>
<p>Hobbies: {{ hobbies.join(', ') }}</p>
</div>
</template>
4. Component Events: Emitting Data Out
While props pass data down, events enable child components to send data up to their parent. Children emit events using $emit, and parents listen to them with v-on (or @ shorthand).
Emitting Events with $emit
In the child component, use emit (from defineEmits() in Vue 3) to trigger an event with optional data.
Example: A Reusable Button Component
<!-- CustomButton.vue -->
<template>
<button
class="custom-btn"
@click="handleClick"
>
{{ label }}
</button>
</template>
<script setup>
const props = defineProps({
label: {
type: String,
required: true
}
});
// Declare emitted events
const emit = defineEmits(['clicked', 'hovered']);
const handleClick = () => {
// Emit 'clicked' event with data (timestamp)
emit('clicked', {
buttonId: 'submit-btn',
timestamp: new Date().toISOString()
});
};
</script>
<style scoped>
.custom-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: #42b983;
color: white;
cursor: pointer;
}
</style>
Handling Events in the Parent
The parent component listens to the child’s events using @event-name and defines a handler:
<!-- ParentComponent.vue -->
<template>
<div>
<CustomButton
label="Submit"
@clicked="onButtonClicked"
@hovered="onButtonHovered"
/>
</div>
</template>
<script setup>
import CustomButton from './CustomButton.vue';
const onButtonClicked = (eventData) => {
console.log('Button clicked!', eventData);
// Output: Button clicked! { buttonId: 'submit-btn', timestamp: '2024-05-20T12:34:56.789Z' }
};
const onButtonHovered = () => {
console.log('Button hovered!');
};
</script>
Event Modifiers
Vue provides event modifiers to simplify common event handling tasks (e.g., preventing default behavior, stopping propagation):
<!-- Prevent default form submission -->
<form @submit.prevent="handleSubmit">...</form>
<!-- Stop event propagation -->
<div @click.stop="handleDivClick">
<button @click="handleButtonClick">Click me</button>
</div>
5. Component Composition: Reusability & Organization
Slots: Content Distribution
Slots allow you to inject content into a child component’s template, making components more flexible. They act as placeholders for parent-provided content.
Default Slot
A component with a default slot accepts content between its opening and closing tags:
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-body">
<slot>Default content (shown if no content is provided)</slot>
</div>
</div>
</template>
<style scoped>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
}
</style>
Usage in Parent:
<Card>
<h2>Welcome!</h2>
<p>This content is injected into the Card's default slot.</p>
</Card>
Named Slots
For more control, use named slots to inject content into specific parts of the child component:
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">Default Header</slot>
</div>
<div class="card-body">
<slot>Default Body</slot>
</div>
<div class="card-footer">
<slot name="footer">Default Footer</slot>
</div>
</div>
</template>
Usage in Parent:
<Card>
<template #header> <!-- Shorthand for v-slot:header -->
<h2>My Custom Card</h2>
</template>
<p>This is the body content.</p> <!-- Injected into default slot -->
<template #footer>
<button @click="submit">Save</button>
</template>
</Card>
Scoped Slots
Scoped slots allow the child to pass data back to the parent’s slot content. Use v-slot with a variable to access the child’s data:
<!-- UserList.vue -->
<template>
<ul>
<li v-for="user in users" :key="user.id">
<slot :user="user"> <!-- Pass user data to the slot -->
{{ user.name }} <!-- Fallback content -->
</slot>
</li>
</ul>
</template>
<script setup>
const props = defineProps({ users: Array });
</script>
Usage in Parent:
<UserList :users="users">
<template #default="slotProps"> <!-- Access slot data via slotProps -->
<div>
<p>Name: {{ slotProps.user.name }}</p>
<p>Email: {{ slotProps.user.email }}</p>
</div>
</template>
</UserList>
Dynamic Components
Use the <component> element with the :is directive to dynamically switch between components:
<template>
<div>
<button @click="currentComponent = 'Home'">Home</button>
<button @click="currentComponent = 'About'">About</button>
<component :is="currentComponent" />
</div>
</template>
<script setup>
import Home from './Home.vue';
import About from './About.vue';
import { ref } from 'vue';
const currentComponent = ref('Home'); // Tracks the active component
</script>
6. Advanced Component Patterns
Scoped Styles
By default, styles in a component’s <style> tag apply globally. Use <style scoped> to limit styles to the component’s DOM, preventing CSS leakage:
<style scoped>
/* Only affects elements in this component's template */
.card {
background: white;
}
/* Use ::v-deep to target child component styles (Vue 3) */
::v-deep .child-component-class {
color: red;
}
</style>
Teleport
Teleport (Vue 3+) lets you render a component’s content outside its parent DOM tree (e.g., modals that should be attached to <body> for CSS stacking context):
<!-- Modal.vue -->
<template>
<teleport to="body"> <!-- Render content to <body> -->
<div class="modal-overlay" v-if="isOpen">
<div class="modal">
<slot />
<button @click="close">Close</button>
</div>
</div>
</teleport>
</template>
<script setup>
const props = defineProps({ isOpen: Boolean });
const emit = defineEmits(['close']);
const close = () => emit('close');
</script>
Provide/Inject
For deep component trees (e.g., grandparent → grandchild), provide and inject avoid “prop drilling” (passing props through intermediate components).
Parent Component (Provider):
<script setup>
import { provide } from 'vue';
// Provide a value to descendants
provide('theme', 'dark');
provide('user', { name: 'John', role: 'Admin' });
</script>
Descendant Component (Injector):
<script setup>
import { inject } from 'vue';
// Inject the provided values
const theme = inject('theme', 'light'); // 'light' is fallback if not provided
const user = inject('user');
</script>
7. Best Practices for Developing Custom UI Components
Single Responsibility Principle
Each component should do one thing and do it well. Avoid “god components” with excessive logic. For example, a DataTable component should handle table rendering, not data fetching (delegate that to a parent or composable).
Accessibility (a11y)
Ensure components are usable by everyone:
- Use semantic HTML (e.g.,
<button>,<nav>). - Add ARIA attributes (e.g.,
aria-label,aria-expandedfor modals). - Support keyboard navigation (e.g.,
@keydown.enterfor buttons).
Testing
Test components with tools like Vue Test Utils to ensure they behave as expected:
// Example test for CustomButton (using Vitest)
import { mount } from '@vue/test-utils';
import CustomButton from './CustomButton.vue';
test('emits "clicked" event when clicked', async () => {
const wrapper = mount(CustomButton, { props: { label: 'Click Me' } });
await wrapper.find('button').trigger('click');
expect(wrapper.emitted('clicked')).toBeTruthy();
});
Documentation
Document components with tools like Storybook to showcase usage, props, and events. Example Storybook story:
// Button.stories.js
import CustomButton from './CustomButton.vue';
export default {
title: 'Components/CustomButton',
component: CustomButton,
argTypes: {
label: { control: 'text' },
onClick: { action: 'clicked' }
}
};
export const Primary = (args ) => ({
components: { CustomButton },
setup() { return { args }; },
template: '<CustomButton v-bind="args" />'
});
Primary.args = { label: 'Primary Button' };
8. Conclusion
Vue.js components are the foundation of modern, maintainable UIs. By mastering props, events, slots, and advanced patterns like teleport and provide/inject, you can build reusable, scalable components that streamline development. Remember to follow best practices like single responsibility, accessibility, and testing to ensure your components are robust and user-friendly.
Start small—build simple components like buttons or cards, then combine them into complex UIs. With Vue’s component model, the possibilities are endless!