Vue 3 Composition API: ref, reactive, and Beyond
The Composition API is Vue 3's answer to the question: "how do I reuse stateful logic without mixins?" After working with it on several production projects, I can confidently say it makes large applications significantly easier to reason about. Let's explore the key primitives.
Why the Composition API?
The Options API groups code by option type (data, methods, computed…). As components grow, related logic gets scattered. The Composition API lets you group code by feature, which keeps things cohesive.
ref vs reactive
Both create reactive state, but there are important differences:
import { ref, reactive } from 'vue'
// ref: wraps a primitive (access via .value)
const count = ref(0)
count.value++
// reactive: wraps an object (no .value needed)
const user = reactive({ name: 'Didik', role: 'dev' })
user.name = 'Budi'
A useful rule of thumb: use ref for single values and reactive for objects with multiple related properties.
computed Properties
import { ref, computed } from 'vue'
const firstName = ref('Didik')
const lastName = ref('Prabowo')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// "Didik Prabowo"
Computed properties are cached based on their reactive dependencies, so they only re-evaluate when firstName or lastName changes.
watch & watchEffect
import { ref, watch, watchEffect } from 'vue'
const query = ref('' )
// watch: explicit source, runs on change
watch(query, (newVal, oldVal) => {
console.log(`Search changed from "${oldVal}" to "${newVal}"`)
}, { immediate: true })
// watchEffect: auto-tracks dependencies
watchEffect(() => {
console.log('Current query:', query.value)
})
Building a Custom Composable
This is where the Composition API really shines. Here's a reusable useFetch composable:
// composables/useFetch.ts
import { ref } from 'vue'
export function useFetch<T>(url: string) {
const data = ref<T | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then((json) => { data.value = json })
.catch((err) => { error.value = err.message })
.finally(() => { loading.value = false })
return { data, loading, error }
}
// In any component:
const { data: users, loading } = useFetch<User[]>('/api/users')
Lifecycle Hooks
import { onMounted, onUnmounted, onBeforeMount } from 'vue'
onBeforeMount(() => console.log('Before mount'))
onMounted(() => console.log('Mounted'))
onUnmounted(() => console.log('Unmounted — clean up here!'))
Key Takeaways
- Use
reffor primitives,reactivefor objects. - Computed values are lazy and cached — prefer them over methods for derived state.
watchis explicit;watchEffectis automatic.- Extract logic into composables to keep components lean and testable.
The Composition API might feel unfamiliar at first, but once it clicks you will never want to go back.
