Vue 3 Composition API: 10 Best Practices for Clean Code

Master the Vue 3 Composition API with these essential best practices. Learn how to write maintainable, reusable, and performant Vue components.

Alex Chen
Alex Chen
December 20, 2024 12 min read
Vue 3 Composition API: 10 Best Practices for Clean Code

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!

Advertisement

In-Article Ad

Dev Mode

Share this article

Alex Chen

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