Vue.js has become one of the most popular frontend frameworks thanks to its simplicity and flexibility. But creating components that are both functional and maintainable requires thoughtful design. Here are ten tips to help you craft Vue components that your future self (and teammates) will thank you for.
Type component props with Typescript for full type safety and spot-on autocompletion while creating a self-documenting component API.
<!-- UserProfileCard.vue -->
<script setup lang="ts">
// Define a type for your props
interface UserProfileProps {
userProfile: User
showAvatar?: boolean
avatarSize?: 'small' | 'medium' | 'large'
}
// Use withDefaults for default values
const props = withDefaults(defineProps<UserProfileProps>(), {
showAvatar: true,
avatarSize: 'medium'
})
</script>
Typing event payloads with TypeScript makes responding to events intuitive and type safe.
<!-- UsersLis.vue -->
<script setup lang="ts">
// Define event types
defineEmits<{
'update:selected': [id: number]
'profile-view': [user: User]
}>()
// When emitting events
const emit = defineEmits(['update:selected', 'profile-view'])
function selectUser(user: User) {
emit('update:selected', user.id)
emit('profile-view', user)
}
</script>
Extract and reuse logic with composables.
<!--UserProfileCard.vue-->
<script setup lang="ts">
// Contains logic that is slightly complex
// but could be used anywhere a user object is available
import { useUserStatus } from '@/composables/useUserStatus'
const props = defineProps<{ user: User }>()
const { isOnline, statusText } = useUserStatus(props.user)
</script>
This approach creates focused modules of functionality that can be used across multiple components and helps better focus component definitions. Learn how to create your own composables from scratch in our course dedicated to the topic or wield powerful off-the-shelf composables from VueUse.
Break larger components into smaller ones with clear responsibilities:
<!--UserDashboard.vue - Parent component that composes smaller components-->
<template>
<div class="dashboard">
<UserHeader :user="user" />
<UserStats :statistics="userStats" />
<RecentActivity :activities="recentActivities" />
</div>
</template>
<script setup lang="ts">
import UserHeader from './UserHeader.vue'
import UserStats from './UserStats.vue'
import RecentActivity from './RecentActivity.vue'
import { useUserData } from '@/composables/useUserData'
const { user, userStats, recentActivities } = useUserData()
</script>
Smaller components are easier to understand, test, and maintain.
Define interfaces for your component's state to improve type safety.
<script setup lang="ts">
import { ref } from 'vue'
interface TaskItem {
id: number
title: string
completed: boolean
dueDate?: Date
priority: 'low' | 'medium' | 'high'
}
const tasks = ref<TaskItem[]>([])
function addTask(title: string, priority: TaskItem['priority'] = 'medium') {
tasks.value.push({
id: Date.now(),
title,
completed: false,
priority
})
}
</script>
This approach catches errors early and serves as documentation.
Avoid calculating values in your template when you could use computed properties instead.
<script setup>
const user = ref({...})
const fullName = computed(()=> `${user.firstName} ${user.lastName}`)
</script>
<template>
<!-- 😕 Logic in template -->
<div>{{ user.firstName + ' ' + user.lastName }}</div>
<!-- 😄 Using computed property -->
<div>{{ fullName }}</div>
</template>
Computed properties are cached and are more readable than template expressions.
Create robust components that handle all states. This pattern ensures a great user experience regardless of the request outcome.
<script setup lang="ts">
import { ref } from 'vue'
import type { User } from '@/types'
const user = ref<User | null>(null)
const loading = ref(true)
const error = ref<Error | null>(null)
async function fetchUserData(userId: string) {
loading.value = true
error.value = null
try {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) throw new Error('Failed to fetch user data')
user.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e : new Error('Unknown error')
user.value = null
} finally {
loading.value = false
}
}
</script>
<template>
<div>
<LoadingSpinner v-if="loading" />
<ErrorMessage v-else-if="error" :message="error.message" @retry="fetchUserData" />
<UserProfile v-else-if="user" :user="user" />
<EmptyState v-else message="No user data available" />
</div>
</template>
Alternately, employ the Suspense component to handle loading state at a single point further up the component tree.
Utilize typed slots for flexible, type-safe component composition:
<!-- DataTable.vue -->
<script setup lang="ts">
interface TableColumn<T> {
key: keyof T
label: string
}
defineProps<{
data: T[]
columns: TableColumn<T>[]
}>()
</script>
<template>
<table>
<thead>
<tr>
<th v-for="column in columns" :key="column.key">{{ column.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data" :key="item.id">
<td v-for="column in columns" :key="column.key">
<!-- Allow customization of cell rendering -->
<slot :name="`cell-${String(column.key)}`" :item="item" :value="item[column.key]">
{{ item[column.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- Usage -->
<DataTable :data="users" :columns="columns">
<template #cell-status="{ value, item }">
<StatusBadge :status="value" :user="item" />
</template>
</DataTable>
This pattern allows parent components to customize child component content while maintaining type safety.
Make your components style-flexible with CSS variables:
<style scoped>
.button {
--button-bg: var(--primary-color, #3490dc);
--button-text: var(--primary-text-color, white);
--button-radius: var(--border-radius, 4px);
background-color: var(--button-bg);
color: var(--button-text);
border-radius: var(--button-radius);
padding: 8px 16px;
border: none;
cursor: pointer;
}
.button:hover {
--button-bg: var(--primary-hover-color, #2779bd);
}
</style>
This approach allows component styling to adapt to different themes while maintaining component encapsulation. Alternately use TailwindCSS to style components with flexible design tokens.
Use TypeScript and JSDoc to create self-documenting components:
<script setup lang="ts">
/**
* Displays user information in a compact card format
*
* @example
* <UserProfileCard
* :user="currentUser"
* @profile-click="showFullProfile"
* />
*/
import { type PropType } from 'vue'
/**
* User data structure
*/
export interface User {
/** Unique identifier for the user */
id: number
/** User's display name */
name: string
/** URL to the user's avatar image */
avatarUrl?: string
/** User's current status */
status: 'online' | 'away' | 'offline'
}
const props = defineProps<{
/** User object to display */
user: User
/** Whether to show detailed information */
detailed?: boolean
}>()
const emit = defineEmits<{
/** Fired when the profile card is clicked */
'profile-click': [userId: number]
}>()
</script>
Good documentation makes your components more maintainable and easier to use and JS Docs are picked up by all modern IDE’s and displayed on component hover.
There you have it! By leveraging Vue's Composition API with script setup and TypeScript, you can create components that are not just functional but also type-safe, maintainable, and a joy to work with. These modern patterns help catch errors earlier in development and make your code more self-documenting. If you’d like to learn more component design patterns then checkout our course: **Vue Component Design: Master Scalable Vue.js Patterns.**
It covers more strategies for creating components that scale, such as the:
Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.
More than 200.000 users have already joined us. You are welcome too!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.