Vue.js 3 Composition API Guide
Vue.js 3 introduced the Composition API as a powerful new way to organize and reuse component logic. While the Options API remains fully supported, the Composition API offers better TypeScript support, more flexible code organization, and improved logic extraction patterns. Whether you're building a new Vue 3 project or migrating from Vue 2, mastering the Composition API is essential for modern Vue development.
In this comprehensive guide, we'll explore every aspect of the Composition API—from basic reactivity primitives like ref and reactive to advanced patterns like composables. You'll learn through practical examples and diagrams that make even complex concepts crystal clear.
- The fundamentals:
ref,reactive, and reactivity in Vue 3 - Computed properties and watchers in the Composition API
- Lifecycle hooks:
onMounted,onUpdated,onUnmounted, and more - Building reusable composables (custom hooks)
- Comparing Options API vs Composition API
- Best practices and real-world patterns
- Migrating from Options API to Composition API
Why the Composition API?
Before diving into the code, let's understand why Vue 3 introduced the Composition API. The Options API, while intuitive for beginners, has limitations when components grow large or when you need to share logic between components.
The Composition API allows you to organize code by logical concern rather than option type
Better Code Organization
Group related logic together instead of splitting across data, methods, computed, and watch options.
Reusable Logic
Extract and share stateful logic between components using composables (similar to React hooks).
TypeScript Support
First-class TypeScript support with better type inference and less boilerplate.
Smaller Bundle Size
Better tree-shaking because imports are explicit and unused features can be eliminated.
Reactivity Fundamentals
The Composition API provides two primary ways to create reactive state: ref and reactive. Understanding when to use each is crucial for effective Vue 3 development.
ref() - Reactive References
The ref() function creates a reactive reference that wraps any value—primitives, objects, or arrays. Access the value using the .value property in JavaScript (but not in templates, where Vue auto-unwraps).
import { ref } from 'vue'
export default {
setup() {
// Create reactive references
const count = ref(0)
const message = ref('Hello Vue 3!')
const user = ref({ name: 'John', age: 25 })
// Access and modify using .value
function increment() {
count.value++ // Must use .value in JavaScript
}
function updateUser() {
user.value.name = 'Jane' // Deep reactivity
}
// Return values for template
return { count, message, user, increment, updateUser }
}
}
<template>
<div>
<!-- No .value needed in templates! -->
<p>Count: {{ count }}</p>
<p>{{ message }}</p>
<p>User: {{ user.name }}, Age: {{ user.age }}</p>
<button @click="increment">Increment</button>
</div>
</template>
reactive() - Reactive Objects
The reactive() function creates a deeply reactive proxy of an object. Unlike ref, you don't need .value—you access properties directly. However, it only works with objects (not primitives).
import { reactive } from 'vue'
export default {
setup() {
// Create a reactive object
const state = reactive({
count: 0,
user: {
name: 'John',
email: 'john@example.com'
},
items: ['Apple', 'Banana', 'Orange']
})
// Direct property access - no .value needed!
function increment() {
state.count++
}
function addItem(item) {
state.items.push(item) // Array methods are reactive
}
function updateUser(name) {
state.user.name = name // Nested objects are reactive
}
return { state, increment, addItem, updateUser }
}
}
Don't destructure reactive() objects—you'll lose reactivity! Use toRefs() if you need to destructure:
// ❌ BAD - loses reactivity
const { count } = state
// ✅ GOOD - maintains reactivity
const { count } = toRefs(state)
ref vs reactive: When to Use Which
| Aspect | ref() | reactive() |
|---|---|---|
| Value Types | Any (primitives, objects, arrays) | Objects and arrays only |
| Access in JS | Via .value |
Direct property access |
| Reassignment | Can replace entire value | Cannot replace entire object |
| Destructuring | Safe to destructure | Loses reactivity (use toRefs) |
| Best For | Primitives, single values | Complex objects, forms |
When in doubt, use ref(). It's more flexible and the .value syntax makes it clear when you're working with reactive state. Many Vue developers prefer using ref() exclusively for consistency.
Computed Properties
Computed properties in the Composition API work similarly to the Options API but are created using the computed() function. They're automatically cached and only re-evaluate when their reactive dependencies change.
import { ref, computed } from 'vue'
export default {
setup() {
const firstName = ref('John')
const lastName = ref('Doe')
const items = ref([
{ name: 'Apple', price: 1.5 },
{ name: 'Banana', price: 0.75 },
{ name: 'Orange', price: 2.0 }
])
// Read-only computed (most common)
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
// Computed with getter and setter
const fullNameWritable = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(newValue) {
const [first, last] = newValue.split(' ')
firstName.value = first
lastName.value = last || ''
}
})
// Computed for derived data
const totalPrice = computed(() => {
return items.value.reduce((sum, item) => sum + item.price, 0)
})
const expensiveItems = computed(() => {
return items.value.filter(item => item.price > 1)
})
return {
firstName, lastName, fullName, fullNameWritable,
items, totalPrice, expensiveItems
}
}
}
Computed properties cache their result and only re-compute when dependencies change
Watchers
The Composition API provides two ways to watch reactive state: watch() for watching specific sources and watchEffect() for automatic dependency tracking.
watch() - Explicit Watching
import { ref, reactive, watch } from 'vue'
export default {
setup() {
const count = ref(0)
const searchQuery = ref('')
const user = reactive({ name: 'John', age: 25 })
// Watch a single ref
watch(count, (newVal, oldVal) => {
console.log(`Count changed: ${oldVal} → ${newVal}`)
})
// Watch with options
watch(searchQuery, async (newQuery) => {
if (newQuery.length > 2) {
const results = await fetchSearchResults(newQuery)
// Update results...
}
}, {
debounce: 300, // Only in Vue 3.4+
immediate: false // Don't run on mount
})
// Watch a reactive object property (use getter)
watch(
() => user.name,
(newName) => {
console.log(`User name changed to: ${newName}`)
}
)
// Watch multiple sources
watch(
[count, () => user.age],
([newCount, newAge], [oldCount, oldAge]) => {
console.log(`Count or age changed`)
}
)
// Deep watch entire object
watch(user, (newUser) => {
console.log('User object changed', newUser)
}, { deep: true })
return { count, searchQuery, user }
}
}
watchEffect() - Automatic Tracking
Unlike watch(), watchEffect() automatically tracks any reactive dependencies used inside its callback and runs immediately on mount.
import { ref, watchEffect } from 'vue'
export default {
setup() {
const count = ref(0)
const message = ref('Hello')
// Runs immediately and re-runs when count or message changes
watchEffect(() => {
console.log(`Count: ${count.value}, Message: ${message.value}`)
// Vue automatically tracks count and message as dependencies
})
// With cleanup function (useful for subscriptions)
watchEffect((onCleanup) => {
const timer = setInterval(() => {
console.log('Tick', count.value)
}, 1000)
// Cleanup runs before effect re-runs or on unmount
onCleanup(() => {
clearInterval(timer)
})
})
// Stop a watcher manually
const stop = watchEffect(() => { /* ... */ })
// Later: stop() to stop watching
return { count, message }
}
}
Lifecycle Hooks
The Composition API provides lifecycle hooks as functions that you import and call inside setup(). Each Options API lifecycle hook has a corresponding Composition API function prefixed with on.
Lifecycle hooks execute at different phases of a component's life
import {
ref,
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted
} from 'vue'
export default {
setup() {
const count = ref(0)
const elementRef = ref(null)
onBeforeMount(() => {
console.log('Component is about to mount')
// DOM not available yet
})
onMounted(() => {
console.log('Component mounted!')
// DOM is now available
console.log(elementRef.value) // Access DOM element
// Common: fetch data, set up subscriptions
fetchData()
})
onBeforeUpdate(() => {
console.log('Component is about to update')
})
onUpdated(() => {
console.log('Component updated!')
})
onBeforeUnmount(() => {
console.log('Component is about to unmount')
// Start cleanup
})
onUnmounted(() => {
console.log('Component unmounted!')
// Clean up: remove event listeners, cancel subscriptions
})
return { count, elementRef }
}
}
Composables: Reusable Logic
One of the most powerful features of the Composition API is the ability to extract and reuse stateful logic through composables. A composable is simply a function that uses Composition API features and returns reactive state and methods.
Create a Composable
Extract reusable logic into a function, conventionally named with a "use" prefix (e.g., useMouse, useFetch).
Return Reactive State
Return refs, reactive objects, computed properties, and functions that components can use.
Use in Any Component
Import and call the composable in any component's setup() to get independent instances of the state.
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
watchEffect(async () => {
// toValue() handles both refs and plain values
const urlValue = toValue(url)
loading.value = true
error.value = null
try {
const response = await fetch(urlValue)
data.value = await response.json()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
return { data, error, loading }
}
<script setup>
import { useMouse } from './composables/useMouse'
import { useFetch } from './composables/useFetch'
// Use mouse position
const { x, y } = useMouse()
// Fetch data
const { data: users, loading, error } = useFetch('/api/users')
</script>
<template>
<div>
<p>Mouse: ({{ x }}, {{ y }})</p>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>
Script Setup Syntax
Vue 3.2 introduced <script setup>, a compile-time syntactic sugar that makes the Composition API even more concise. Variables and imports are automatically exposed to the template without needing to return them.
<script>
import { ref, computed } from 'vue'
import MyComponent from './MyComponent.vue'
export default {
components: { MyComponent },
props: {
title: String
},
emits: ['update'],
setup(props, { emit }) {
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
emit('update', count.value)
}
return { count, doubled, increment }
}
}
</script>
<script setup>
import { ref, computed } from 'vue'
import MyComponent from './MyComponent.vue'
// Props with defineProps
const props = defineProps({
title: String
})
// Emits with defineEmits
const emit = defineEmits(['update'])
// Everything is automatically exposed!
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
emit('update', count.value)
}
</script>
<!-- MyComponent is auto-registered! -->
- Less boilerplate—no need to return values or register components
- Better TypeScript type inference
- Better runtime performance (compiled to render function in same scope)
- Smaller bundle size
Migration from Options API
If you're migrating from the Options API, here's a quick reference for translating common patterns:
| Options API | Composition API |
|---|---|
data() { return { count: 0 } } |
const count = ref(0) |
computed: { doubled() { return this.count * 2 } } |
const doubled = computed(() => count.value * 2) |
methods: { increment() { this.count++ } } |
function increment() { count.value++ } |
watch: { count(newVal) { ... } } |
watch(count, (newVal) => { ... }) |
mounted() { ... } |
onMounted(() => { ... }) |
this.$refs.myElement |
const myElement = ref(null) |
Best Practices
1. Organize by Feature, Not by Option
Group related reactive state, computed properties, and methods together. This makes it easier to understand and maintain your code.
// ✅ Good: Grouped by feature
// User feature
const user = ref(null)
const isLoggedIn = computed(() => !!user.value)
function login() { /* ... */ }
function logout() { /* ... */ }
// Cart feature
const cartItems = ref([])
const cartTotal = computed(() => /* ... */)
function addToCart(item) { /* ... */ }
2. Extract Logic into Composables
When logic becomes complex or needs to be reused, extract it into a composable function. Name it with the use prefix.
// composables/useAuth.js
export function useAuth() {
const user = ref(null)
const isLoggedIn = computed(() => !!user.value)
async function login(credentials) { /* ... */ }
function logout() { /* ... */ }
return { user, isLoggedIn, login, logout }
}
3. Use ref() for Primitives, reactive() for Objects
While both work, using ref() for primitives and reactive() for complex objects is a common convention. Alternatively, use ref() for everything for consistency.
// Convention 1: Mixed
const count = ref(0) // Primitive
const form = reactive({ // Object
name: '',
email: ''
})
// Convention 2: All refs (also valid)
const count = ref(0)
const form = ref({ name: '', email: '' })
4. Always Clean Up Side Effects
Use onUnmounted or the cleanup function in watchEffect to clean up subscriptions, timers, and event listeners.
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
Conclusion
The Vue.js 3 Composition API represents a significant evolution in how we build Vue applications. By providing a more flexible way to organize component logic, better TypeScript support, and powerful composition patterns through composables, it addresses many limitations developers faced with the Options API in larger applications.
Key takeaways from this guide:
- ref() and reactive() are the foundation of reactivity in the Composition API
- computed() creates cached, reactive derived values
- watch() and watchEffect() let you react to state changes
- Lifecycle hooks are available as imported functions
- Composables enable powerful logic reuse across components
- <script setup> provides the most concise syntax
Remember, the Options API isn't deprecated—both APIs can coexist, and you can even mix them in the same component. Start by trying the Composition API in new components or when refactoring complex ones. As you get comfortable, you'll appreciate the improved organization and reusability it provides.
