Table of Contents
The Composition API is one of the most significant features introduced in Vue 3. It provides a more flexible and powerful way to organize component logic compared to the Options API. In this article, weβll explore 10 best practices that will help you write cleaner, more maintainable Vue 3 code.
1. Use Composables for Reusable Logic
Composables are the heart of the Composition API. They allow you to extract and reuse stateful logic across components.
// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
return {
count,
doubleCount,
increment,
decrement,
reset
}
}
Usage in a component:
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
const { count, doubleCount, increment, decrement, reset } = useCounter(10)
</script>
2. Prefer <script setup> Syntax
The <script setup> syntax is more concise and provides better TypeScript inference. Itβs the recommended approach for new Vue 3 projects.
<!-- β Avoid: Verbose defineComponent -->
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup() {
const message = ref('Hello')
return { message }
}
})
</script>
<!-- β
Prefer: Concise script setup -->
<script setup lang="ts">
import { ref } from 'vue'
const message = ref('Hello')
</script>
3. Type Your Refs and Reactive Objects
Always provide explicit types for better IDE support and catch errors early.
import { ref, reactive } from 'vue'
// β Implicit typing
const user = ref(null)
// β
Explicit typing
interface User {
id: number
name: string
email: string
}
const user = ref<User | null>(null)
// For reactive objects
const state = reactive<{
users: User[]
loading: boolean
error: string | null
}>({
users: [],
loading: false,
error: null
})
4. Use toRefs for Destructuring Reactive Objects
When destructuring reactive objects, use toRefs to maintain reactivity.
import { reactive, toRefs } from 'vue'
const state = reactive({
firstName: 'John',
lastName: 'Doe'
})
// β Loses reactivity
const { firstName, lastName } = state
// β
Maintains reactivity
const { firstName, lastName } = toRefs(state)
5. Organize Code by Feature, Not by Type
Group related logic together instead of separating by lifecycle hooks or refs.
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
// β Organized by type
const users = ref([])
const posts = ref([])
const userLoading = ref(false)
const postLoading = ref(false)
onMounted(() => {
fetchUsers()
fetchPosts()
})
// β
Organized by feature
// --- User Feature ---
const users = ref([])
const userLoading = ref(false)
async function fetchUsers() {
userLoading.value = true
// ...
}
onMounted(fetchUsers)
// --- Post Feature ---
const posts = ref([])
const postLoading = ref(false)
async function fetchPosts() {
postLoading.value = true
// ...
}
onMounted(fetchPosts)
</script>
6. Use watchEffect for Automatic Dependency Tracking
watchEffect automatically tracks reactive dependencies, making your code cleaner.
import { ref, watchEffect, watch } from 'vue'
const searchQuery = ref('')
const category = ref('all')
// β Manual dependency listing
watch([searchQuery, category], ([query, cat]) => {
performSearch(query, cat)
})
// β
Automatic dependency tracking
watchEffect(() => {
performSearch(searchQuery.value, category.value)
})
7. Handle Cleanup in Lifecycle Hooks
Always clean up side effects to prevent memory leaks.
import { onMounted, onUnmounted } from 'vue'
// Event listeners
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
// Or use the cleanup function in watchEffect
watchEffect((onCleanup) => {
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
.then(/* ... */)
onCleanup(() => {
controller.abort()
})
})
8. Define Props and Emits with TypeScript
Use proper TypeScript interfaces for props and emits.
<script setup lang="ts">
interface Props {
title: string
count?: number
items: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0
})
// Typed emits
interface Emits {
(e: 'update', value: string): void
(e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
// Usage
emit('update', 'new value')
emit('delete', 123)
</script>
9. Use Provide/Inject for Deep Component Trees
Avoid prop drilling by using provide/inject with proper typing.
// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface UserContext {
user: Ref<User | null>
login: (credentials: Credentials) => Promise<void>
logout: () => void
}
export const UserKey: InjectionKey<UserContext> = Symbol('user')
// Parent component
import { provide, ref } from 'vue'
import { UserKey } from '@/types/injection-keys'
const user = ref<User | null>(null)
provide(UserKey, {
user,
login: async (credentials) => { /* ... */ },
logout: () => { user.value = null }
})
// Child component (any depth)
import { inject } from 'vue'
import { UserKey } from '@/types/injection-keys'
const userContext = inject(UserKey)
if (userContext) {
console.log(userContext.user.value)
}
10. Lazy Load Heavy Components
Use defineAsyncComponent for components that arenβt immediately needed.
import { defineAsyncComponent } from 'vue'
// Basic lazy loading
const HeavyChart = defineAsyncComponent(() =>
import('@/components/HeavyChart.vue')
)
// With loading and error states
const HeavyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 3000
})
Bonus: Composable Naming Convention
Follow a consistent naming pattern for composables:
// β
Good naming
useAuth()
useLocalStorage()
useFetch()
useDebounce()
useMousePosition()
// β Avoid
getAuth()
authHook()
AuthComposable()
Conclusion
The Composition API offers incredible flexibility for organizing your Vue 3 applications. By following these best practices, youβll write code thatβs:
- More maintainable: Logic is grouped by feature
- More reusable: Composables can be shared across components
- More type-safe: TypeScript integration is first-class
- More testable: Pure functions are easier to test
Start applying these patterns in your projects today and experience the difference they make in code quality and developer experience.
Whatβs your favorite Composition API pattern? Share your thoughts in the comments below!
In-Article Ad
Dev Mode
Alex Chen
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.
Related Articles
Pinia State Management: The Complete Vue 3 Guide
Master Pinia, the official Vue 3 state management library. Learn stores, actions, getters, plugins, and best practices.
TypeScript Utility Types: The Complete Guide
Master TypeScript's built-in utility types and learn to create your own. Covers Partial, Pick, Omit, Record, and advanced custom types.
Building a Production-Ready REST API with Node.js and TypeScript
Learn to build scalable REST APIs with Node.js, Express, TypeScript, and PostgreSQL. Includes authentication, validation, and error handling.