
Introduction
Your component has 400 lines. The data is at the top, the methods are at the bottom, and the computed properties are somewhere in between. Related logic is scattered across five sections. That is the Options API working as designed. The Composition API lets you organize by feature instead of by option type.
It does not replace the Options API. You do not need it for a simple component. But when a file handles multiple concerns and you are scrolling constantly to keep the mental model loaded — that is when it matters.
This tutorial assumes you know Vue basics — templates, props, events, and the Options API. If you are new to Vue entirely, start with the official docs and come back here afterward.
Why the Composition API Exists
The real reason is composables. Reusable logic extraction. Mixins were the old answer and they were terrible — silent name conflicts, no way to tell where a property came from, sharing logic between mixins was awkward. Composables fix all three problems because they are just functions. Explicit imports. Scoped names through destructuring. One composable calls another freely.
TypeScript support matters too. The type inference is excellent because everything is plain functions, not a this context that Vue magically assembles at runtime. And <script setup> compiles more efficiently. Smaller bundles.
But do not rewrite working Options API components just because this exists. A component under 100 lines that does one thing? Often cleaner with Options. Teams that spend weeks on "Composition API migrations" with zero user-facing value are solving a problem they do not have.
Reactive State: ref and reactive
Use ref. Skip reactive unless you have a reason.
Using ref for Primitive Values
ref wraps a value in a reactive reference object. .value in script, auto-unwrapped in templates.
<script setup>
import { ref } from 'vue'
// Create reactive references
const count = ref(0)
const username = ref('Marcus')
const isLoading = ref(false)
// Access and modify with .value in script
function increment() {
count.value++
console.log(count.value) // 1, 2, 3...
}
function resetCounter() {
count.value = 0
}
</script>
<template>
<!-- No .value needed in templates -->
<h2>Count: {{ count }}</h2>
<p>Hello, {{ username }}</p>
<button @click="increment">Add One</button>
<button @click="resetCounter">Reset</button>
</template>Why the .value? JavaScript primitives are not tracked by reference. The object wrapper lets Vue intercept reads and writes.
Using reactive for Objects
For objects and arrays, reactive skips the .value ceremony. The entire object becomes deeply reactive.
<script setup>
import { reactive, ref } from 'vue'
// reactive works great for objects
const formState = reactive({
name: '',
email: '',
role: 'developer',
preferences: {
theme: 'dark',
notifications: true
}
})
// No .value needed -- direct property access
function submitForm() {
console.log(`Submitting for ${formState.name}`)
console.log(`Theme: ${formState.preferences.theme}`)
}
// Arrays work too
const todos = reactive([
{ id: 1, text: 'Learn ref', done: true },
{ id: 2, text: 'Learn reactive', done: false }
])
function addTodo(text) {
todos.push({ id: Date.now(), text, done: false })
}
</script>
<template>
<form @submit.prevent="submitForm">
<input v-model="formState.name" placeholder="Name" />
<input v-model="formState.email" placeholder="Email" />
<button type="submit">Save</button>
</form>
</template>The gotcha with reactive: destructure it and you lose reactivity. The extracted properties become plain values. Template stops updating and you cannot figure out why? Check whether you destructured a reactive object. I default to ref for everything because it is more consistent, and ref holds any value type — primitives, objects, arrays. But this is a team decision. Pick one and commit.
Computed Properties and Watchers
Computed Properties
computed tracks dependencies automatically. Cached. Will not re-run if the data has not moved.
<script setup>
import { ref, computed } from 'vue'
const searchQuery = ref('')
const sortOrder = ref('asc')
const products = ref([
{ name: 'Vue Mastery Course', price: 29 },
{ name: 'TypeScript Handbook', price: 19 },
{ name: 'Composition API Guide', price: 0 },
{ name: 'Pinia State Management', price: 15 },
])
// Computed: filters AND sorts reactively
const filteredProducts = computed(() => {
let result = products.value.filter(p =>
p.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
result.sort((a, b) =>
sortOrder.value === 'asc' ? a.price - b.price : b.price - a.price
)
return result
})
// Computed: derived summary stats
const totalValue = computed(() =>
filteredProducts.value.reduce((sum, p) => sum + p.price, 0)
)
const freeCount = computed(() =>
filteredProducts.value.filter(p => p.price === 0).length
)
</script>
<template>
<input v-model="searchQuery" placeholder="Search products..." />
<p>{{ filteredProducts.length }} results | Total: ${{ totalValue }}</p>
<ul>
<li v-for="product in filteredProducts" :key="product.name">
{{ product.name }} - ${{ product.price }}
</li>
</ul>
</template>
Watchers: watch and watchEffect
watch is explicit — you tell it what to observe, it gives you old and new values. watchEffect auto-tracks whatever reactive state you read inside it. Like computed but for side effects instead of derived values.
<script setup>
import { ref, watch, watchEffect } from 'vue'
const searchQuery = ref('')
const results = ref([])
const page = ref(1)
const debugInfo = ref('')
// watch: explicit source, access old + new value
// A new search resets to page 1. It does not fetch by itself.
watch(searchQuery, (newVal, oldVal) => {
console.log(`Search changed: "${oldVal}" -> "${newVal}"`)
page.value = 1
})
// watch multiple sources at once: this is the only watcher that fetches.
// watch() has no debounce option, so debounce by hand. onCleanup runs
// when the sources change again and cancels the pending timer.
watch(
[searchQuery, page],
([newQuery, newPage], _oldValues, onCleanup) => {
const timer = setTimeout(() => {
console.log(`Fetching page ${newPage} for "${newQuery}"`)
fetchResults(newQuery, newPage)
}, 300)
onCleanup(() => clearTimeout(timer))
}
)
// watchEffect: auto-tracks dependencies
watchEffect(() => {
debugInfo.value = `Query: "${searchQuery.value}" | Page: ${page.value} | Results: ${results.value.length}`
})
let controller = null
async function fetchResults(query, pg = 1) {
controller?.abort() // drop the previous, now stale request
if (!query) { results.value = []; return }
controller = new AbortController()
try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(query)}&page=${pg}`,
{ signal: controller.signal }
)
results.value = await res.json()
} catch (err) {
if (err.name !== 'AbortError') throw err
}
}
</script>
Vue's watch options are immediate, deep, flush and once (plus the debug hooks). There is no debounce option, so the timer and onCleanup above do that job. For a reusable version, use the useDebounce composable later in this article or VueUse's watchDebounced.
watch is lazy. Will not run until the source changes. Add { immediate: true } if you need it on creation. watchEffect always runs immediately — has to, so it can discover what it depends on.
Lifecycle Hooks in Composition API
Same hooks, different import. That is it. Every Options API hook has a Composition API equivalent prefixed with on. The setup function itself runs during beforeCreate/created, so those two do not exist — put initialization code directly in <script setup>.
beforeCreate/created— Just use<script setup>directlybeforeMount—onBeforeMount()mounted—onMounted()beforeUpdate—onBeforeUpdate()updated—onUpdated()beforeUnmount—onBeforeUnmount()unmounted—onUnmounted()
<script setup>
import { ref, onMounted, onUnmounted, onBeforeUnmount } from 'vue'
const chartRef = ref(null) // Template ref for the DOM element
const dataPoints = ref([])
let chartInstance = null
let pollingInterval = null
// This code runs during setup (equivalent to created)
console.log('Component is being set up')
onMounted(() => {
// DOM is now available -- safe to initialize chart
chartInstance = new ChartLibrary(chartRef.value, {
data: dataPoints.value,
type: 'line'
})
// Start polling for live data
pollingInterval = setInterval(async () => {
const res = await fetch('/api/metrics')
const newPoint = await res.json()
dataPoints.value.push(newPoint)
chartInstance.update(dataPoints.value)
}, 5000)
})
onBeforeUnmount(() => {
// Clean up before DOM removal
if (chartInstance) chartInstance.destroy()
})
onUnmounted(() => {
// Clean up timers, subscriptions, event listeners
clearInterval(pollingInterval)
console.log('Chart component fully cleaned up')
})
</script>
<template>
<div ref="chartRef" class="chart-container"></div>
</template>Cleanup sits right next to setup. In the Options API, chart initialization in mounted and chart destruction in beforeUnmount could be hundreds of lines apart. Here they are adjacent. New developer reads the setup, immediately sees the teardown.
Building Reusable Composables
This is the actual payoff. Everything before this section was table stakes. The Composition API exists so you can write composables.
A composable is a function that uses Vue's reactivity system and returns reactive state or methods. It owns its cleanup, its side effects, its watchers. Self-contained. You have ten components that all need data fetching with loading states and error handling? Write useFetch once. Every component calls it and gets data, error, isLoading back. With mixins, you could not tell where a property came from, naming conflicts were silent, and sharing logic between mixins required ugly workarounds. Composables are explicit function calls — you see the import, you see the destructured return values, you can command-click to the source. Three practical composables you can drop into any Vue 3 project:
useFetch: Reusable Data Fetching
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
let requestId = 0
async function execute() {
// toValue() unwraps refs and resolves getters
const resolvedUrl = toValue(url)
if (!resolvedUrl) return
const id = ++requestId // lets us ignore responses that arrive late
isLoading.value = true
error.value = null
try {
const response = await fetch(resolvedUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const json = await response.json()
if (id === requestId) data.value = json
} catch (err) {
if (id === requestId) error.value = err
} finally {
if (id === requestId) isLoading.value = false
}
}
// Auto-fetch when URL changes (if URL is reactive)
watchEffect(() => {
execute()
})
return { data, error, isLoading, retry: execute }
}
// Usage in a component:
// const { data: users, error, isLoading } = useFetch('/api/users')
// const { data: post } = useFetch(() => `/api/posts/${postId.value}`)useLocalStorage: Persistent Reactive State
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
// Read initial value from storage or use default
const stored = localStorage.getItem(key)
const data = ref(
stored !== null ? JSON.parse(stored) : defaultValue
)
// Sync to localStorage whenever the value changes
watch(
data,
(newValue) => {
if (newValue === null || newValue === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(newValue))
}
},
{ deep: true }
)
return data
}
// Usage:
// const theme = useLocalStorage('theme', 'dark')
// const savedFilters = useLocalStorage('filters', { sort: 'date', order: 'desc' })
// theme.value = 'light' // Automatically saves to localStorageuseDebounce: Throttle Rapid Changes
import { ref, watch, onUnmounted } from 'vue'
export function useDebounce(source, delay = 300) {
const debounced = ref(source.value)
let timeout = null
watch(source, (newVal) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debounced.value = newVal
}, delay)
})
// Clean up on component unmount
onUnmounted(() => clearTimeout(timeout))
return debounced
}
// Usage in a search component:
// const searchInput = ref('')
// const debouncedSearch = useDebounce(searchInput, 500)
// watch(debouncedSearch, (val) => fetchResults(val))Same recipe every time. Create reactive state, set up logic, return what the consumer needs. Name them with the use prefix — not required, but it signals that the function uses Vue reactivity and should be called inside a setup context.
Props, Emits and Provide/Inject
defineProps and defineEmits are compiler macros in <script setup>. No import needed. Both work well with TypeScript for type-safe component interfaces.
provide/inject replaces prop drilling. Parent provides data, any descendant injects it regardless of depth. Pass a ref and all injecting components update when it changes. Dashboard layout where every nested widget needs the current theme? Root layout provides it once. Done.
But keep this straight: provide/inject is for library-level or layout-level state. Application-wide state belongs in Pinia. Which is built on the Composition API anyway.
Migrating from Options API
Do not migrate everything at once.
Write new components with <script setup>. Gets the team comfortable without touching existing code. Then identify mixins or duplicated logic and rewrite them as composables — Options API components can use composables through the setup() method. Refactoring a large component for other reasons? That is the time to switch it over. Stable components nobody touches? Leave them alone.
data()becomesref()orreactive()computed: {}becomescomputed()methods: {}becomes plain functions in<script setup>watch: {}becomeswatch()orwatchEffect()mounted()becomesonMounted()propsbecomesdefineProps()emitsbecomesdefineEmits()this.$refsbecomesref(null)with template refs- Mixins become composable functions
The stumbling block everyone hits: forgetting .value in script. Template renders fine, script logic does nothing. Check .value first. Becomes second nature after a day.
Turn on eslint-plugin-vue early. It catches missing .value and composables called outside setup. Cheaper than debugging.
Conclusion
Read the VueUse source code. Best way to internalize how composables should be structured. And know when not to extract — a composable should represent a meaningful, reusable concern, not every three lines of code that sit near each other. If only one component will ever call it, it is not worth extracting yet.
Is the Composition API better? For components over 100 lines, absolutely. For a 20-line component with two reactive properties, the Options API is clearer. Use both.