Vue.js Component Composition
Introduction
Component composition is a fundamental concept in Vue.js that allows developers to build complex user interfaces by combining smaller, reusable components. Rather than creating large, monolithic components that handle many responsibilities, the composition approach encourages you to break down your UI into manageable pieces that can be composed together.
This pattern follows the UNIX philosophy: "Do one thing and do it well." By focusing on creating components with a single responsibility, you make your code more maintainable, testable, and reusable across your application.
In this guide, we'll explore different approaches to component composition in Vue.js, from basic parent-child relationships to more advanced patterns.
Basic Component Composition
The most fundamental type of component composition is nesting components within each other to create a parent-child relationship.
Parent-Child Component Relationship
Let's start with a basic example of a parent component that includes child components:
<!-- ParentComponent.vue -->
<template>
<div class="parent">
<h1>Parent Component</h1>
<ChildComponent />
<ChildComponent />
</div>
</template>
<script>
import ChildComponent from './ChildComponent.vue'
export default {
components: {
ChildComponent
}
}
</script>
<!-- ChildComponent.vue -->
<template>
<div class="child">
<h2>Child Component</h2>
<p>This is a reusable child component!</p>
</div>
</template>
<script>
export default {
name: 'ChildComponent'
}
</script>
In this example, ParentComponent
imports and registers ChildComponent
, then uses it twice in its template. This is the simplest form of composition.
Component Communication
For components to work together effectively, they need to communicate. Vue provides several methods for this:
Props (Parent to Child)
Props allow a parent component to pass data down to a child component:
<!-- ParentComponent.vue -->
<template>
<div class="parent">
<h1>User Information</h1>
<UserCard
name="John Doe"
role="Developer"
:years-of-experience="5"
/>
</div>
</template>
<script>
import UserCard from './UserCard.vue'
export default {
components: {
UserCard
}
}
</script>
<!-- UserCard.vue -->
<template>
<div class="user-card">
<h2>{{ name }}</h2>
<p>Role: {{ role }}</p>
<p>Experience: {{ yearsOfExperience }} years</p>
</div>
</template>
<script>
export default {
props: {
name: {
type: String,
required: true
},
role: {
type: String,
default: 'Employee'
},
yearsOfExperience: {
type: Number,
default: 0
}
}
}
</script>
Events (Child to Parent)
For communication in the opposite direction, child components can emit events that parent components listen for:
<!-- ChildComponent.vue -->
<template>
<div>
<button @click="notifyParent">Send Message to Parent</button>
</div>
</template>
<script>
export default {
methods: {
notifyParent() {
this.$emit('message-sent', 'Hello from child component!')
}
}
}
</script>
<!-- ParentComponent.vue -->
<template>
<div>
<h1>Parent Component</h1>
<p>Message from child: {{ message }}</p>
<ChildComponent @message-sent="receiveMessage" />
</div>
</template>
<script>
import ChildComponent from './ChildComponent.vue'
export default {
components: {
ChildComponent
},
data() {
return {
message: 'No message yet'
}
},
methods: {
receiveMessage(msg) {
this.message = msg
}
}
}
</script>
Slots for Flexible Composition
Slots provide a powerful mechanism for creating flexible component templates that can be customized by the parent component.
Basic Slot Usage
<!-- BaseCard.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">Default Header</slot>
</div>
<div class="card-body">
<slot>Default content</slot>
</div>
<div class="card-footer">
<slot name="footer">Default Footer</slot>
</div>
</div>
</template>
<script>
export default {
name: 'BaseCard'
}
</script>
<style scoped>
.card {
border: 1px solid #ddd;
border-radius: 4px;
}
.card-header, .card-footer {
background-color: #f5f5f5;
padding: 10px;
}
.card-body {
padding: 20px;
}
</style>
<!-- ParentComponent.vue -->
<template>
<div>
<BaseCard>
<template #header>
<h2>Custom Header</h2>
</template>
<p>This is the main content of the card.</p>
<p>You can put any content here.</p>
<template #footer>
<small>Created on: {{ new Date().toLocaleDateString() }}</small>
</template>
</BaseCard>
</div>
</template>
<script>
import BaseCard from './BaseCard.vue'
export default {
components: {
BaseCard
}
}
</script>
This creates a flexible card component where the parent can customize the header, body, and footer content.
Scoped Slots
Scoped slots allow child components to pass data back to parent-provided slot content:
<!-- UserList.vue -->
<template>
<div>
<h2>User List</h2>
<ul>
<li v-for="user in users" :key="user.id">
<slot :user="user" :index="user.id">
{{ user.name }}
</slot>
</li>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
users: [
{ id: 1, name: 'Alice', role: 'Designer' },
{ id: 2, name: 'Bob', role: 'Developer' },
{ id: 3, name: 'Charlie', role: 'Manager' }
]
}
}
}
</script>
<!-- App.vue -->
<template>
<div>
<UserList>
<template v-slot:default="slotProps">
<div class="user-item">
<strong>{{ slotProps.user.name }}</strong>
<em> ({{ slotProps.user.role }})</em>
</div>
</template>
</UserList>
</div>
</template>
<script>
import UserList from './UserList.vue'
export default {
components: {
UserList
}
}
</script>
This allows the parent component to define how each user is rendered while the child component manages the user data and looping logic.
Composing Complex UI with Component Hierarchy
In real applications, you'll likely have multiple layers of components. Let's look at a more realistic example of a dashboard:
<!-- Dashboard.vue -->
<template>
<div class="dashboard">
<AppHeader :user="currentUser" @logout="handleLogout" />
<div class="dashboard-content">
<Sidebar :menu-items="menuItems" @menu-click="handleMenuClick" />
<main>
<StatsSummary :stats="stats" />
<DataTable
v-if="selectedView === 'table'"
:items="tableData"
:columns="tableColumns"
@row-click="handleRowClick"
/>
<DataVisualizer
v-else-if="selectedView === 'chart'"
:chart-data="chartData"
:chart-options="chartOptions"
/>
</main>
</div>
<AppFooter />
</div>
</template>
<script>
import AppHeader from './components/AppHeader.vue'
import Sidebar from './components/Sidebar.vue'
import StatsSummary from './components/StatsSummary.vue'
import DataTable from './components/DataTable.vue'
import DataVisualizer from './components/DataVisualizer.vue'
import AppFooter from './components/AppFooter.vue'
export default {
components: {
AppHeader,
Sidebar,
StatsSummary,
DataTable,
DataVisualizer,
AppFooter
},
data() {
return {
currentUser: { name: 'John Doe', role: 'Admin' },
menuItems: [
{ id: 'table', name: 'Data Table' },
{ id: 'chart', name: 'Data Visualization' }
],
selectedView: 'table',
stats: {
totalUsers: 1205,
activeUsers: 847,
revenue: '$12,350'
},
tableData: [/* ... */],
tableColumns: [/* ... */],
chartData: {/* ... */},
chartOptions: {/* ... */}
}
},
methods: {
handleLogout() {
// Handle logout logic
},
handleMenuClick(menuId) {
this.selectedView = menuId
},
handleRowClick(item) {
// Handle row click
}
}
}
</script>
In this example, Dashboard.vue
is a container component that coordinates several child components to create a complete interface. Each child component has a specific responsibility:
AppHeader
: Navigation and user infoSidebar
: Menu optionsStatsSummary
: Key metrics displayDataTable
: Tabular data presentationDataVisualizer
: Chart-based data visualizationAppFooter
: Footer content
Dynamic Component Composition
Vue provides the <component>
element with the is
attribute for dynamically switching between components:
<template>
<div>
<select v-model="currentTabComponent">
<option value="tab-posts">Posts</option>
<option value="tab-archive">Archive</option>
<option value="tab-settings">Settings</option>
</select>
<component :is="currentTabComponent" class="tab"></component>
</div>
</template>
<script>
import TabPosts from './TabPosts.vue'
import TabArchive from './TabArchive.vue'
import TabSettings from './TabSettings.vue'
export default {
components: {
'tab-posts': TabPosts,
'tab-archive': TabArchive,
'tab-settings': TabSettings
},
data() {
return {
currentTabComponent: 'tab-posts'
}
}
}
</script>
This approach allows for conditionally rendering different components in the same place based on dynamic conditions.
Composition API for Component Logic
In Vue 3, the Composition API provides a way to organize component logic by feature rather than by options type. While this is not directly about visual component composition, it enables better composition of component logic:
<template>
<div>
<h2>User Profile</h2>
<div v-if="isLoading">Loading...</div>
<div v-else>
<UserBasicInfo :user="user" />
<UserStats :stats="userStats" />
<UserPosts :posts="posts" />
</div>
</div>
</template>
<script>
import { ref, reactive, computed, onMounted } from 'vue'
import UserBasicInfo from './UserBasicInfo.vue'
import UserStats from './UserStats.vue'
import UserPosts from './UserPosts.vue'
import { useUserData } from '../composables/useUserData'
import { useUserStats } from '../composables/useUserStats'
import { useUserPosts } from '../composables/useUserPosts'
export default {
components: {
UserBasicInfo,
UserStats,
UserPosts
},
props: {
userId: {
type: Number,
required: true
}
},
setup(props) {
const isLoading = ref(true)
// Use composables to organize related logic
const { user } = useUserData(props.userId)
const { userStats } = useUserStats(props.userId)
const { posts } = useUserPosts(props.userId)
onMounted(async () => {
// Initialize data
await Promise.all([
user.value.load(),
userStats.value.load(),
posts.value.load()
])
isLoading.value = false
})
return {
isLoading,
user,
userStats,
posts
}
}
}
</script>
Best Practices for Component Composition
-
Single Responsibility Principle: Each component should have one job. If a component grows too complex, split it into smaller components.
-
Shallow Component Trees: Try to keep your component tree relatively flat. Deep nesting can lead to "prop drilling" and maintenance difficulties.
-
Consistent Naming Conventions:
- Use PascalCase for component names (e.g.,
UserProfile
) - Use kebab-case for custom elements in templates (e.g.,
<user-profile>
)
- Use PascalCase for component names (e.g.,
-
Clear Component Interfaces: Define explicit props and events for each component with validation.
-
Don't Over-Engineer: Avoid creating too many tiny components that fragment your codebase. Find a balance between reusability and maintainability.
-
Document Your Components: Add comments or use tools like Storybook to document how your components should be used.
Common Component Composition Patterns
Container/Presentational Pattern
This pattern separates data handling from presentation:
<!-- UserListContainer.vue (Container) -->
<template>
<div>
<UserList
:users="users"
:is-loading="isLoading"
:error="error"
@refresh="fetchUsers"
/>
</div>
</template>
<script>
import UserList from './UserList.vue'
import usersApi from '../api/users'
export default {
components: { UserList },
data() {
return {
users: [],
isLoading: true,
error: null
}
},
created() {
this.fetchUsers()
},
methods: {
async fetchUsers() {
this.isLoading = true
this.error = null
try {
this.users = await usersApi.getUsers()
} catch (error) {
this.error = 'Failed to load users: ' + error.message
} finally {
this.isLoading = false
}
}
}
}
</script>
<!-- UserList.vue (Presentational) -->
<template>
<div>
<div v-if="isLoading">Loading users...</div>
<div v-else-if="error">{{ error }}</div>
<div v-else>
<h2>User List</h2>
<button @click="$emit('refresh')">Refresh</button>
<ul>
<li v-for="user in users" :key="user.id">
{{ user.name }} ({{ user.email }})
</li>
</ul>
<p v-if="users.length === 0">No users found</p>
</div>
</div>
</template>
<script>
export default {
props: {
users: {
type: Array,
default: () => []
},
isLoading: {
type: Boolean,
default: false
},
error: {
type: String,
default: null
}
}
}
</script>
Higher-Order Component Pattern
Higher-order components (HOCs) wrap components with additional functionality:
<!-- withAuth.js (Higher-order component function) -->
<script>
import { defineComponent, h } from 'vue'
import LoginForm from './LoginForm.vue'
export default function withAuth(Component) {
return defineComponent({
name: 'WithAuth',
data() {
return {
isAuthenticated: false
}
},
created() {
// Check if user is authenticated
this.isAuthenticated = localStorage.getItem('auth_token') !== null
},
render() {
if (!this.isAuthenticated) {
return h(LoginForm, {
onLogin: () => {
this.isAuthenticated = true
}
})
}
return h(Component, {
...this.$attrs
})
}
})
}
</script>
<!-- Usage -->
<script>
import withAuth from './withAuth'
import UserDashboard from './UserDashboard.vue'
export default {
name: 'SecureDashboard',
components: {
AuthenticatedDashboard: withAuth(UserDashboard)
}
}
</script>
<template>
<div>
<h1>Secure Area</h1>
<AuthenticatedDashboard />
</div>
</template>
Summary
Component composition is a powerful approach in Vue.js that allows you to:
- Build complex UIs from smaller, reusable components
- Establish clear communication patterns between components
- Create flexible, reusable components using slots
- Organize your application with a logical component hierarchy
- Dynamically switch between components as needed
By mastering component composition, you'll be able to build Vue applications that are more maintainable, testable, and scalable. The patterns and techniques covered in this guide provide a solid foundation for creating well-structured Vue applications.
Additional Resources
To deepen your understanding of Vue.js component composition:
Exercises
-
Basic Component Nesting: Create a
BlogPost
component that uses smaller components such asPostHeader
,PostContent
, andCommentSection
. -
Props and Events Practice: Build a
TodoList
component that receives todos as props and emits events when items are completed or deleted. -
Slot Challenge: Create a reusable
Modal
component that uses named slots for the header, body, and footer sections. -
Dynamic Components: Build a tab interface where clicking different tabs shows different components.
-
Component Pattern Implementation: Implement the Container/Presentational pattern for a product listing page that fetches data from an API.
By working through these exercises, you'll gain practical experience with component composition and be better prepared to apply these concepts in your own Vue.js projects.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)