ref, reactive, Watchers, and Composables: The Composition API in Practice
Vue 3's Composition API replaces the Options API's implicit reactivity and mixin-based sharing with explicit primitives. This post covers ref vs reactive, the role of toRefs, the difference between watch and watchEffect, and how composables replace mixins without their structural problems.
Vue 2’s Options API made reactivity implicit: declare state in data(), and the framework handled the rest. Vue 3’s Composition API shifts that responsibility to the developer. Reactive state is declared explicitly using ref or reactive, and the choice between them has consequences for how that state can be passed, destructured, and consumed.
ref and reactive: What Each One Covers
reactive() accepts an object and returns a Proxy of it. Every property on that Proxy is reactive: reads are tracked, writes trigger updates. Because reactive() wraps an object, it cannot hold a primitive directly.
import { reactive } from 'vue'
const state = reactive({ count: 0 })
state.count++ // reactive read and write
ref() accepts any value, including primitives such as numbers and strings, and wraps it in a small object with a .value property. That property has a getter and setter that perform the same dependency tracking a Proxy would.
import { ref } from 'vue'
const count = ref(0)
count.value++ // reactive read and write through .value
Inside a <script setup> block, refs are unwrapped automatically in templates, so {{ count }} renders the value without .value. Outside templates, .value is always required.
reactive() has three documented limitations. It cannot hold primitives. It loses its reactive connection if the variable is reassigned to a new object, because the Proxy bound to the original reference no longer applies. It also loses reactivity when properties are destructured into local variables, because those variables become plain copies that do not route through the Proxy.
const state = reactive({ count: 0 })
// Reactivity is lost — count is now a plain number
let { count } = state
count++ // does not affect state.count
// Reactivity is also lost — the new object is not the tracked Proxy
let state2 = reactive({ count: 0 })
state2 = reactive({ count: 1 }) // breaks the reactive connection
ref() avoids all three problems. A ref is itself an object, so it can be passed into functions and reassigned through .value without losing its reactive wrapper. The Vue documentation recommends ref() as the primary API for declaring reactive state, reserving reactive() for cases where you are managing a structured object that will never be replaced entirely.
toRefs: Preserving Reactivity Through Destructuring
When a reactive object’s properties need to be destructured, plain destructuring breaks the Proxy connection. toRefs() converts each property of a reactive object into an individual ref that stays linked to the original.
import { reactive, toRefs } from 'vue'
const state = reactive({ x: 0, y: 0 })
// Without toRefs: x and y are plain numbers, no longer reactive
const { x, y } = state
// With toRefs: x and y are refs linked to state.x and state.y
const { x, y } = toRefs(state)
x.value = 10 // updates state.x
toRefs creates a ref for each property whose .value getter and setter delegate to the parent reactive object. Writing to x.value is equivalent to writing to state.x. This is the standard pattern for returning reactive state from a composable while allowing the caller to destructure freely.
watchEffect and watch: Two Models of Dependency Tracking
Vue 3 provides two watcher APIs with different philosophies. watchEffect() runs a callback immediately and automatically tracks every reactive dependency that is accessed during the callback’s synchronous execution. When any tracked dependency changes, the callback re-runs.
import { ref, watchEffect } from 'vue'
const todoId = ref(1)
const data = ref(null)
watchEffect(async () => {
const response = await fetch(`/api/todos/${todoId.value}`)
data.value = await response.json()
})
In this example, todoId.value is accessed before the first await, so it is registered as a dependency automatically. When todoId changes, the effect re-runs. No source argument is needed. One important constraint applies: in async callbacks, only reactive properties accessed before the first await are tracked. Properties read after that point are not registered as dependencies.
watch() is explicit. You declare the source, and the callback is only invoked when that source changes. The callback receives the new value and the old value as separate arguments, which watchEffect does not provide.
import { ref, watch } from 'vue'
const question = ref('')
const answer = ref('')
watch(question, async (newQuestion, oldQuestion) => {
if (newQuestion.includes('?')) {
answer.value = 'Thinking...'
const res = await fetch('https://yesno.wtf/api')
answer.value = (await res.json()).answer
}
})
watch() also accepts a getter function as its source, which is required when watching a property of a reactive object rather than the object itself.
const obj = reactive({ count: 0 })
// Does not work — obj.count is a plain number at the call site
watch(obj.count, (count) => { console.log(count) })
// Correct — pass a getter that reads the property reactively
watch(() => obj.count, (count) => { console.log(count) })
The practical distinction is that watchEffect is appropriate when you want to synchronise a side effect with all of its dependencies and do not need the old value. watch is appropriate when you need to react to a specific, named source, compare old and new values, or avoid running the callback until the source actually changes.
Composables: Encapsulating Stateful Logic
A composable is a function that uses Vue’s Composition API to encapsulate stateful logic that can be reused across components. By convention, composable names start with a use prefix in camelCase: useMouse, useFetch, useEventListener. This distinguishes them from ordinary utility functions and signals that they manage reactive state or register lifecycle hooks.
Mouse position tracking requires two reactive values, an event listener registered on mount, and cleanup on unmount. Extracted into a composable, that logic becomes portable.
// mouse.js
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 }
}
The composable returns an object of refs. Each component that calls useMouse() receives its own independent copies of x and y, with no shared state between callers. The lifecycle hooks are scoped to the calling component instance, so cleanup happens automatically on unmount.
<script setup>
import { useMouse } from './mouse.js'
const { x, y } = useMouse()
</script>
<template>Mouse position is at: {{ x }}, {{ y }}</template>
Returning a plain object of refs rather than a reactive object is deliberate. It allows callers to destructure freely without losing reactivity, because each named value is already a ref rather than a raw Proxy property.
How Composables Replace Mixins
Vue 3’s documentation identifies three structural problems with mixins. The source of any property injected by a mixin is not traceable from the component file alone. Two mixins that define the same key produce a silent collision where one definition is discarded. Mixins that depend on methods or data properties provided by the consuming component create invisible contracts that cannot be enforced by the language.
Composables resolve all three. Return values are explicitly destructured at the call site, so the origin of every property is traceable to a named import. A name conflict between two composables surfaces immediately as an identifier clash, resolved by renaming at the destructuring step rather than being silently dropped. Composables receive inputs as arguments and produce outputs as return values, so there are no implicit expectations of the consuming component’s shape.
import { useUserProfile } from './useUserProfile.js'
import { usePostList } from './usePostList.js'
// If both composables return isLoading, the conflict is resolved explicitly
const { isLoading: profileLoading, user } = useUserProfile()
const { isLoading: postsLoading, posts } = usePostList(user)
The mixin equivalent of this would silently discard one of the two isLoading definitions with no indication of which one survived. The composable version makes the conflict visible the moment the second destructuring is written.
What You Can Do Now
Take a component that manages an async data load and extract the loading state into a composable. The refactor demonstrates all of the primitives above in combination.
// useFetch.js
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
watchEffect(() => {
data.value = null
error.value = null
fetch(toValue(url))
.then((res) => res.json())
.then((json) => (data.value = json))
.catch((err) => (error.value = err))
})
return { data, error }
}
The composable accepts a plain URL string or a ref. toValue() normalises both into a plain value, so the caller can pass either. watchEffect re-runs the fetch automatically whenever the URL changes. The return value is two refs destructured freely at the call site.
<script setup>
import { ref } from 'vue'
import { useFetch } from './useFetch.js'
const postId = ref(1)
const { data, error } = useFetch(() => `/api/posts/${postId.value}`)
</script>
<template>
<div v-if="error">{{ error.message }}</div>
<div v-else-if="data">{{ data.title }}</div>
<div v-else>Loading...</div>
</template>
The URL is passed as a getter function so that watchEffect tracks postId.value as a dependency. Changing postId.value triggers a new fetch automatically. There is no watch call, no explicit dependency list, and no lifecycle hook in the component itself.