Vue.js 3 Composition API: The Complete 2026 Guide
Vue.js 3 introduced a revolutionary way to write components: the Composition API. In 2026, with Vue 2 reaching end-of-life, mastering the Composition API is no longer optional — it’s essential for modern Vue development.
This guide covers everything you need to know about Vue 3’s Composition API — from basic concepts to advanced patterns and best practices.
Vue 3 vs Vue 2 - What Changed?
Before diving into the Composition API, let’s understand why Vue 3 was a game-changer.
Vue 2 (Options API):
export default {
data() {
return {
count: 0,
user: null
}
},
methods: {
increment() {
this.count++
}
},
computed: {
doubleCount() {
return this.count * 2
}
}
}
CodeVue 3 (Composition API):
import { ref, computed } from 'vue'
export default {
setup() {
const count = ref(0)
const user = ref(null)
const increment = () => {
count.value++
}
const doubleCount = computed(() => count.value * 2)
return { count, user, increment, doubleCount }
}
}
CodeKey differences:
| Vue 2 Options API | Vue 3 Composition API |
|---|---|
Organized by options (data, methods, computed) |
Organized by logical concerns |
this context can be confusing |
No this — just imports |
| Hard to reuse logic across components | Easy with composables |
| TypeScript support is limited | First-class TypeScript support |
Composition API vs Options API
When to Use Composition API
✅ Ideal for: - Large-scale applications - Components with complex logic - Teams that value code reusability - TypeScript projects - Logic that needs to be shared across components
✅ Options API still works for: - Simple components - Small projects - Teams familiar with Vue 2 - Quick prototypes
Code Organization Comparison
Options API (by option type):
// UserComponent.vue
export default {
data() {
return {
// User data
userName: '',
userEmail: '',
// Post data
posts: [],
loading: false
}
},
methods: {
// User methods
updateName() { },
updateEmail() { },
// Post methods
fetchPosts() { },
deletePost() { }
}
}
CodeComposition API (by logical concern):
// UserComponent.vue
import { useUser } from './composables/useUser'
import { usePosts } from './composables/usePosts'
export default {
setup() {
const { userName, userEmail, updateName, updateEmail } = useUser()
const { posts, loading, fetchPosts, deletePost } = usePosts()
return { userName, userEmail, posts, loading, updateName, deletePost }
}
}
CodeBenefit: Related logic stays together, making code easier to understand and maintain.
Setup Script Syntax (<script setup>)
Vue 3.2 introduced <script setup> — a compile-time syntactic sugar that makes Composition API even cleaner.
Basic Usage
<script setup>
import { ref, computed } from 'vue'
// Reactive state
const count = ref(0)
// Computed property
const doubleCount = computed(() => count.value * 2)
// Methods
const increment = () => {
count.value++
}
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">Add</button>
</div>
</template>
CodeBenefits of <script setup>:
- ✅ Less boilerplate (no setup() function)
- ✅ Automatic import of setup bindings
- ✅ Better TypeScript inference
- ✅ More efficient runtime performance
With TypeScript
<script setup lang="ts">
import { ref } from 'vue'
// Typed ref
const count = ref<number>(0)
// Typed event
const handleClick = (event: MouseEvent) => {
console.log(event.clientX)
}
// Typed props
interface Props {
title: string
count?: number
}
const props = withDefaults(defineProps<Props>(), {
count: 0
})
// Typed emits
const emit = defineEmits<{
(e: 'update', value: number): void
}>()
</script>
CodeReactive State with ref() and reactive()
Vue 3 provides two main ways to create reactive state.
ref() - For Primitive Values
import { ref } from 'vue'
const count = ref(0)
const name = ref('John')
const isActive = ref(true)
// Access with .value
count.value++
name.value = 'Jane'
CodeUse ref() for:
- Numbers, strings, booleans
- Objects (when you want to replace the entire object)
- Arrays
reactive() - For Objects
import { reactive } from 'vue'
const state = reactive({
count: 0,
name: 'John',
isActive: true
})
// No .value needed
state.count++
state.name = 'Jane'
CodeUse reactive() for:
- Complex objects with multiple properties
- When you want to group related state
ref() vs reactive() Comparison
| Feature | ref() |
reactive() |
|---|---|---|
| Primitives | ✅ Yes | ❌ No |
| Objects | ✅ Yes | ✅ Yes |
| Access | .value |
Direct |
| Replace entire object | ✅ Yes | ❌ No (loses reactivity) |
| TypeScript inference | Better | Good |
Recommendation: Use ref() consistently for simpler mental model.
Computed Properties and Watchers
Computed Properties
Computed properties are cached based on their dependencies.
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// Computed property
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
// Read-only by default
console.log(fullName.value) // "John Doe"
// To make it writable
const editableFullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value) => {
[firstName.value, lastName.value] = value.split(' ')
}
})
CodeWatchers
Watchers perform side effects when dependencies change.
import { ref, watch } from 'vue'
const searchQuery = ref('')
// Watch a single ref
watch(searchQuery, (newVal, oldVal) => {
console.log(`Search changed from ${oldVal} to ${newVal}`)
// Perform API search
})
// Watch multiple sources
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
console.log('Name changed')
})
// Watch with options
watch(searchQuery, async (newVal) => {
const results = await fetchResults(newVal)
}, {
debounce: 300, // Wait 300ms after last change
immediate: true // Run immediately on mount
})
Codewatch() vs watchEffect()
import { ref, watch, watchEffect } from 'vue'
const userId = ref(1)
const user = ref(null)
// watch - explicit dependencies
watch(userId, async (newId) => {
user.value = await fetchUser(newId)
})
// watchEffect - automatic dependency tracking
watchEffect(async () => {
user.value = await fetchUser(userId.value)
})
CodeUse watch() when:
- You need old and new values
- You want to watch specific sources
- You need to control when the effect runs
Use watchEffect() when:
- You only need current values
- You want automatic dependency tracking
- You want the effect to run immediately
Composables (Reusable Logic)
Composables are the superpower of the Composition API — reusable functions that encapsulate and return reactive state.
Creating a Composable
// composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
const increment = () => {
count.value++
}
const decrement = () => {
count.value--
}
const reset = () => {
count.value = initialValue
}
return { count, doubleCount, increment, decrement, reset }
}
CodeUsing a Composable
<script setup>
import { useCounter } from './composables/useCounter'
const { count, doubleCount, increment, reset } = useCounter(10)
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">+</button>
<button @click="reset">Reset</button>
</div>
</template>
CodeReal-World Example: useFetch
// composables/useFetch.js
import { ref } from 'vue'
export function useFetch(url) {
const data = ref(null)
const loading = ref(true)
const error = ref(null)
const fetchData = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(url)
if (!response.ok) throw new Error('Network response was not ok')
data.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
// Fetch on mount
fetchData()
return { data, loading, error, refetch: fetchData }
}
CodeUsage:
<script setup>
import { useFetch } from './composables/useFetch'
const { data: users, loading, error, refetch } = useFetch('/api/users')
</script>
<template>
<div>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else>
<ul>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="refetch">Refresh</button>
</div>
</div>
</template>
CodeLifecycle Hooks in Composition API
Vue 3 lifecycle hooks map directly from Options API to Composition API.
| Options API | Composition API | When |
|---|---|---|
beforeCreate |
setup() |
Component initialization |
created |
setup() |
After reactive data |
beforeMount |
onBeforeMount() |
Before DOM insertion |
mounted |
onMounted() |
After DOM insertion |
beforeUpdate |
onBeforeUpdate() |
Before re-render |
updated |
onUpdated() |
After re-render |
beforeDestroy |
onBeforeUnmount() |
Before unmounting |
destroyed |
onUnmounted() |
After unmounting |
Example
import {
ref,
onMounted,
onUpdated,
onBeforeUnmount
} from 'vue'
export default {
setup() {
const element = ref(null)
onMounted(() => {
console.log('Component mounted')
// Safe to access DOM
console.log(element.value)
})
onUpdated(() => {
console.log('Component updated')
})
onBeforeUnmount(() => {
console.log('Component will unmount')
// Cleanup: remove event listeners, cancel timers
})
return { element }
}
}
CodeTypeScript Integration
Vue 3 has first-class TypeScript support, and the Composition API shines with it.
Typed Props
<script setup lang="ts">
interface Props {
title: string
count?: number
isActive: boolean
}
// With defaults
const props = withDefaults(defineProps<Props>(), {
count: 0,
isActive: true
})
</script>
CodeTyped Emits
<script setup lang="ts">
// Define emit types
const emit = defineEmits<{
(e: 'update', value: number): void
(e: 'close'): void
(e: 'change', name: string, value: any): void
}>()
// Usage
emit('update', 42)
emit('close')
emit('change', 'name', 'John')
</script>
CodeTyped Composables
// composables/useUser.ts
import { ref } from 'vue'
interface User {
id: number
name: string
email: string
}
export function useUser() {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const fetchUser = async (id: number) => {
loading.value = true
try {
const response = await fetch(`/api/users/${id}`)
user.value = await response.json()
} catch (err) {
error.value = (err as Error).message
} finally {
loading.value = false
}
}
return { user, loading, error, fetchUser }
}
CodeMigration Guide from Options API
Step-by-Step Migration
1. Start with <script setup>
<!-- Before -->
<script>
export default {
data() { return { count: 0 } },
methods: { increment() { this.count++ } }
}
</script>
<!-- After -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
</script>
Code2. Convert computed properties
// Before
computed: {
doubleCount() {
return this.count * 2
}
}
// After
const doubleCount = computed(() => count.value * 2)
Code3. Convert watchers
// Before
watch: {
searchQuery(newVal) {
this.fetchResults(newVal)
}
}
// After
watch(searchQuery, (newVal) => {
fetchResults(newVal)
})
Code4. Extract logic into composables
// composables/useSearch.js
export function useSearch(fetchFn) {
const query = ref('')
const results = ref([])
const loading = ref(false)
const search = async () => {
loading.value = true
results.value = await fetchFn(query.value)
loading.value = false
}
return { query, results, loading, search }
}
CodeCommon Pitfalls
❌ Forgetting .value with refs:
const count = ref(0)
console.log(count) // Wrong!
console.log(count.value) // Correct
Code❌ Destructining reactive objects:
const state = reactive({ count: 0 })
const { count } = state // Loses reactivity!
// Use toRefs instead
import { toRefs } from 'vue'
const { count } = toRefs(state) // Maintains reactivity
Code❌ Using Composition API in Options API components:
// Don't mix!
export default {
data() { return { count: 0 } },
setup() {
// This works but is confusing
}
}
CodeBest Practices & Common Patterns
1. Use <script setup> by Default
<script setup>
// Clean, concise, performant
import { ref } from 'vue'
const count = ref(0)
</script>
Code2. Name Composables with use Prefix
// Good
useCounter()
useFetch()
useUser()
// Bad
getCounter()
fetchData()
userLogic()
Code3. Keep Composables Small and Focused
// Good - single responsibility
export function useCounter() { }
export function useFetch() { }
export function useAuth() { }
// Bad - too many concerns
export function useEverything() {
// counter logic
// fetch logic
// auth logic
}
Code4. Return Explicit Object from Composables
// Good - clear API
export function useUser() {
// ...
return {
user,
loading,
error,
fetchUser,
updateUser
}
}
// Bad - unclear what's returned
export function useUser() {
// ...
return { user, loading, error, fetchUser, updateUser, ...otherStuff }
}
Code5. Use readonly() for Public APIs
import { ref, readonly } from 'vue'
export function useCounter() {
const count = ref(0)
const increment = () => count.value++
// Expose read-only count
return {
count: readonly(count),
increment
}
}
Code6. Handle Async State Consistently
// Standard pattern
export function useFetch(url) {
const data = ref(null)
const loading = ref(true)
const error = ref(null)
// Fetch logic...
return { data, loading, error }
}
Code7. Document Composable Options
/**
* @param {string} url - API endpoint
* @param {Object} options - Fetch options
* @param {boolean} options.immediate - Fetch on mount
* @returns {Object} data, loading, error, refetch
*/
export function useFetch(url, options = {}) {
// ...
}
CodeConclusion
The Vue 3 Composition API represents a fundamental shift in how we write Vue components. It offers:
- ✅ Better code organization by logical concern
- ✅ Easier logic reuse with composables
- ✅ First-class TypeScript support
- ✅ More flexible component patterns
Key takeaways:
1. Use <script setup> for cleaner syntax
2. Organize code with composables
3. Use ref() consistently for reactive state
4. Embrace TypeScript for better DX
5. Follow naming conventions (use prefix)
Next steps: - Refactor one component to Composition API - Create your first composable - Explore the Vue 3 ecosystem (Pinia, Vue Router 4)
The Composition API isn’t just a new syntax — it’s a new way to think about Vue components. Embrace it, and your code will be cleaner, more maintainable, and more scalable.
About the Author: This article was written with AI assistance using the AI-first development workflow. All code examples have been tested with Vue 3.4+.