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 referencesconst count = ref(0)
const username = ref('Marcus')
const isLoading = ref(false)
// Access and modify with .value in scriptfunctionincrement() {
count.value++
console.log(count.value) // 1, 2, 3...
}
functionresetCounter() {
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 objectsconst formState = reactive({
name: '',
email: '',
role: 'developer',
preferences: {
theme: 'dark',
notifications: true
}
})
// No .value needed -- direct property accessfunctionsubmitForm() {
console.log(`Submitting for ${formState.name}`)
console.log(`Theme: ${formState.preferences.theme}`)
}
// Arrays work tooconst todos = reactive([
{ id: 1, text: 'Learn ref', done: true },
{ id: 2, text: 'Learn reactive', done: false }
])
functionaddTodo(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 reactivelyconst 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 statsconst 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 valuewatch(searchQuery, (newVal, oldVal) => {
console.log(`Search changed: "${oldVal}" -> "${newVal}"`)
page.value = 1// Reset to page 1 on new searchfetchResults(newVal)
}, { debounce: 300 })
// watch multiple sources at oncewatch(
[searchQuery, page],
([newQuery, newPage], [oldQuery, oldPage]) => {
console.log(`Fetching page ${newPage} for "${newQuery}"`)
fetchResults(newQuery, newPage)
}
)
// watchEffect: auto-tracks dependencieswatchEffect(() => {
debugInfo.value = `Query: "${searchQuery.value}" | Page: ${page.value} | Results: ${results.value.length}`
})
async functionfetchResults(query, pg = 1) {
if (!query) { results.value = []; return }
const res = awaitfetch(`/api/search?q=${query}&page=${pg}`)
results.value = await res.json()
}
</script>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 elementconst dataPoints = ref([])
let chartInstance = nulllet 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 = newChartLibrary(chartRef.value, {
data: dataPoints.value,
type: 'line'
})
// Start polling for live data
pollingInterval = setInterval(async () => {
const res = awaitfetch('/api/metrics')
const newPoint = await res.json()
dataPoints.value.push(newPoint)
chartInstance.update(dataPoints.value)
}, 5000)
})
onBeforeUnmount(() => {
// Clean up before DOM removalif (chartInstance) chartInstance.destroy()
})
onUnmounted(() => {
// Clean up timers, subscriptions, event listenersclearInterval(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 functionuseFetch(url) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
async functionexecute() {
// toValue() unwraps refs and resolves gettersconst resolvedUrl = toValue(url)
if (!resolvedUrl) return
isLoading.value = true
error.value = nulltry {
const response = awaitfetch(resolvedUrl)
if (!response.ok) {
throw newError(`HTTP ${response.status}: ${response.statusText}`)
}
data.value = await response.json()
} catch (err) {
error.value = err
} finally {
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 functionuseLocalStorage(key, defaultValue) {
// Read initial value from storage or use defaultconst stored = localStorage.getItem(key)
const data = ref(
stored !== null ? JSON.parse(stored) : defaultValue
)
// Sync to localStorage whenever the value changeswatch(
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 functionuseDebounce(source, delay = 300) {
const debounced = ref(source.value)
let timeout = nullwatch(source, (newVal) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debounced.value = newVal
}, delay)
})
// Clean up on component unmountonUnmounted(() =>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.