Vue.js 3 Composition API Guide
You open a Vue 2 component that's grown to 600 lines. The search feature's state is in data(), its filtering is in computed, the API call is in methods, and the debounce lives in watch. To change one feature you scroll through four sections, and when another component needs the same logic, your options are a mixin with name collisions or copy and paste.
The Composition API in Vue 3 fixes that by letting you group code by feature and pull it out into plain functions. The Options API is still fully supported, so this isn't a forced migration. Below I go through ref and reactive, computed values, watchers, lifecycle hooks, composables, <script setup>, and how to translate Options API code, plus the gotchas that tend to bite people the first week.
- 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?
The Options API is easy to pick up, and for small components it's fine. The trouble starts when a component handles several concerns at once, or when two components need the same stateful logic. The diagram shows the difference in how code ends up laid out.
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
Plain variables and functions infer types well, so you write fewer manual annotations than with this-based options.
Smaller Bundle Size
Better tree-shaking because imports are explicit and unused features can be eliminated.
Reactivity Fundamentals
You get two ways to create reactive state: ref and reactive. They overlap a lot, and most confusion with the Composition API comes from mixing up their rules.
ref() - Reactive References
Use ref() to wrap any value: primitives, objects, or arrays. In JavaScript you read and write it through .value. In templates you don't, because Vue unwraps top-level refs for you.
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
reactive() returns a deeply reactive proxy of an object. There's no .value; you access properties directly. The catch is that it only works with objects, arrays, and collections like Map, 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 }
}
}
Destructuring a reactive() object gives you plain values, so your template stops updating and nothing warns you. The same happens if you reassign the whole thing (state = reactive({...})): the template still points at the old proxy. Use toRefs() when 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 handles every value type, and the .value makes it obvious in your script which variables are reactive. The Vue docs themselves recommend ref() as the default, and plenty of teams use nothing else.
Computed Properties
These behave the same as in the Options API, you just create them with computed(). The result is cached and only recalculated when one of its reactive dependencies changes. Keep the getter free of side effects: no API calls, no mutating other state. That's what watchers are for.
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
There are two options here: watch() when you want to name exactly what to watch, and watchEffect() when you want Vue to track whatever the callback reads.
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, // Not a built-in option; Vue ignores it, so debounce yourself
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 }
}
}
- Writing
watch(user.name, ...)passes a plain string, not something reactive. Vue logs[Vue warn]: Invalid watch sourceand the callback never fires. Wrap it in a getter:() => user.name. watch()has nodebounceoption. For search inputs, wrap the callback with a debounce helper (lodash's, orwatchDebouncedfrom VueUse), otherwise every keystroke fires a request.
watchEffect() - Automatic Tracking
watchEffect() runs right away and tracks every reactive value its callback reads, then re-runs when any of them change. Handy, but be careful with async callbacks: only values read before the first await get tracked.
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
Lifecycle hooks become functions you import and call inside setup(). Each Options API hook has a matching function with an on prefix. They have to be registered synchronously during setup; if you call onMounted after an await or inside a setTimeout, Vue warns that there's no active component instance and the hook won't run.
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
This is the part that replaces mixins. You move stateful logic into a plain function (a composable) that uses Composition API features and returns reactive state and methods. Unlike mixins, you can see exactly where each value comes from, and you can rename on destructure if two composables return the same name.
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
Since Vue 3.2 you can write <script setup> instead of a setup() function. It's compile-time sugar: top-level variables and imports are available in the template without a return statement, and imported components don't need registering.
<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 returning values or registering components
- Better TypeScript type inference
- Better runtime performance (compiled to render function in same scope)
- Smaller bundle size
Migration from Options API
Here's how the common Options API patterns translate. The main habit to break is reaching for this, which doesn't exist inside setup().
| 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
Keep each feature's state, computed values, and functions next to each other, so changing one feature means reading one block of 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 a feature gets big or a second component needs it, move it into a composable with a use prefix. I wouldn't extract logic that's only used once and fits in 20 lines; that just adds a file to jump to.
// 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
Both work. One common convention is ref() for primitives and reactive() for objects like forms. The other is ref() for everything. Pick one per project and stick to it.
// 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)
})
When I'd Use the Composition API (and When I Wouldn't)
For a new Vue 3 project, especially one using TypeScript, I'd default to <script setup>. The type inference is better, composables beat mixins for sharing logic, and most of the ecosystem (Pinia, VueUse, Nuxt 3) writes its examples this way.
It isn't automatically the right call, though:
- Small, simple components don't gain much. A form with three fields reads just as well in the Options API.
- A stable Vue 2 or Options API codebase doesn't need rewriting. The Options API isn't deprecated, and both styles can live in the same project (even the same component). Convert components when you're already refactoring them, not as a project of its own.
- A team new to Vue may find the Options API's fixed structure easier to start with. The Composition API gives you freedom to organize code, which also means freedom to make a mess. Without some agreement on grouping by feature, you get one long
setup()that's worse than what you had.
The bugs I'd watch for most after switching: losing reactivity by destructuring reactive() (or props, before Vue 3.5), forgetting .value in script code (a check like if (isLoading) is always true because the ref object is truthy), and registering lifecycle hooks after an await.