14.3 Composition API Core — setup(), ref, reactive, computed, watch, watchEffect
The Core Building Blocks of the Composition API
The Composition API is composed of several reactivity APIs and lifecycle hooks. Understanding the role of each API precisely lets you write even the most complex components with clarity.
| API | Role |
|---|---|
setup() | Entry point for Composition API, or <script setup> |
ref() | Make a single value reactive |
reactive() | Make an object/array reactive |
computed() | A value derived from other reactive values |
watch() | Watch a specific reactive value for changes |
watchEffect() | Auto-tracks reactive values it uses and runs immediately |
setup() Function
setup() is the first function to execute when a component is created — the starting point for Composition API. It receives props and context (emit, attrs, slots) as arguments.
<script>
import { ref } from 'vue'
export default {
// Define props
props: {
initialCount: {
type: Number,
default: 0,
},
},
// setup receives props and context as arguments
setup(props, context) {
const count = ref(props.initialCount)
const increment = () => { count.value++ }
// context.emit: trigger events
const done = () => {
context.emit('complete', count.value)
}
// context.attrs: inherited HTML attributes
console.log(context.attrs)
// context.slots: access slots
console.log(context.slots.default?.())
// Return values and functions for the template
return { count, increment, done }
},
}
</script>
<script setup> — The Modern Standard
<script setup> is a compile-time transformation that automatically generates the setup() function. It produces a clearer structure with less code.
<script setup>
import { ref } from 'vue'
// Define props (compiler macro — no import needed)
const props = defineProps({
initialCount: {
type: Number,
default: 0,
},
})
// Define emits
const emit = defineEmits(['complete'])
const count = ref(props.initialCount)
const increment = () => { count.value++ }
const done = () => { emit('complete', count.value) }
// ← No return needed: all variables/functions are automatically exposed to the template
</script>
ref()
ref() is the most fundamental API for creating a single reactive value. It can wrap primitives (numbers, strings, booleans) as well as objects and arrays.
The .value Property of ref
<script setup>
import { ref } from 'vue'
// Number
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
// String
const name = ref('Vue')
name.value = 'Vue 3' // reactive update
// Boolean
const isVisible = ref(false)
isVisible.value = !isVisible.value
// Array
const items = ref([1, 2, 3])
items.value.push(4) // reactive update
// Object (ref works, but reactive is generally preferred)
const user = ref({ name: 'Alice', age: 30 })
user.value.name = 'Bob' // reactive update
</script>
<template>
<!-- In the template, use without .value (auto-unwrapping) -->
<p>{{ count }}</p>
<p>{{ name }}</p>
<p>{{ isVisible }}</p>
</template>
Auto-Unwrapping
<script setup>
import { ref, reactive } from 'vue'
const count = ref(0)
// Inside a reactive object, .value is not needed (auto-unwrapping)
const state = reactive({ count })
console.log(state.count) // 0 (no .value needed)
state.count++
console.log(state.count) // 1
console.log(count.value) // 1 (same reference)
</script>
<template>
<!-- Auto-unwrapping in templates too -->
<p>{{ count }}</p> <!-- 0, 1, 2, ... -->
</template>
useTemplateRef — Accessing DOM Elements
<script setup>
import { useTemplateRef, onMounted } from 'vue'
// Vue 3.5+: useTemplateRef
const inputEl = useTemplateRef('searchInput')
onMounted(() => {
inputEl.value?.focus() // Auto-focus after DOM mounts
})
// Pre-Vue 3.5 approach
// const inputEl = ref(null)
</script>
<template>
<input ref="searchInput" type="text" placeholder="Search..." />
</template>
reactive()
reactive() creates a deep reactive object or array. Unlike ref, no .value is needed, and changes to nested properties are also tracked.
<script setup>
import { reactive } from 'vue'
const state = reactive({
count: 0,
name: 'Alice',
address: {
city: 'New York',
district: 'Manhattan',
},
hobbies: ['coding', 'reading'],
})
// Direct access without .value
state.count++
state.name = 'Bob'
state.address.city = 'Los Angeles' // Nested properties are reactive too
state.hobbies.push('gaming') // Array mutations are tracked
</script>
When to Use ref vs reactive
<script setup>
import { ref, reactive } from 'vue'
// ✅ Use ref: for a single primitive value, or when you need to replace the whole value
const count = ref(0)
const data = ref(null) // For holding an API response
count.value = 100 // Can replace the whole value
// ✅ Use reactive: for structured state objects with frequent partial updates
const form = reactive({
username: '',
email: '',
password: '',
})
form.username = 'alice' // Natural object access
// ❌ reactive pitfall: assigning a whole new object loses reactivity
// form = { username: 'bob' } // This breaks reactivity!
// Object.assign(form, newData) // The correct way to do a full update
</script>
reactive Gotcha — Losing Reactivity on Destructure
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, name: 'Alice' })
// ❌ Wrong: destructuring loses reactivity
// const { count, name } = state
// count++ // This change won't reflect on screen
// ✅ Correct approach 1: convert with toRefs
const { count, name } = toRefs(state)
count.value++ // Reactivity is maintained (.value is needed)
// ✅ Correct approach 2: access directly via state
state.count++
</script>
computed()
computed() creates a derived value from other reactive values. It only recalculates when its dependencies change, and caches the result.
Basic Usage
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
const age = ref(25)
// Read-only computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
const isAdult = computed(() => age.value >= 18)
const ageMessage = computed(() => `${age.value} years old${isAdult.value ? ' (adult)' : ' (minor)'}`)
console.log(fullName.value) // "John Doe"
console.log(isAdult.value) // true
</script>
<template>
<p>Name: {{ fullName }}</p>
<p>{{ ageMessage }}</p>
</template>
Writable computed (get + set)
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// Computed with both getter and setter
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(newValue) {
// "John Doe" → ["John", "Doe"]
const parts = newValue.split(' ')
firstName.value = parts[0]
lastName.value = parts[1] || ''
},
})
// Getter: read the current value
console.log(fullName.value) // "John Doe"
// Setter: update individual values using the full name
fullName.value = 'Jane Smith'
console.log(firstName.value) // "Jane"
console.log(lastName.value) // "Smith"
</script>
computed vs Methods — Why Caching Matters
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
// computed: only re-runs when dependencies change (cached)
const expensiveComputed = computed(() => {
console.log('computed recalculated') // Only printed when count changes
return count.value * 2
})
// method: runs every time it is called (not cached)
const expensiveMethod = () => {
console.log('method executed') // Printed on every render
return count.value * 2
}
// If both are called 100 times in the template:
// computed: calculates once, returns cached value 100 times
// method: executes the calculation 100 times
</script>
<template>
<!-- computed: same value, no recalculation -->
<p>{{ expensiveComputed }}</p>
<p>{{ expensiveComputed }}</p> <!-- Returns cached value -->
<!-- method: function runs on every call -->
<p>{{ expensiveMethod() }}</p>
<p>{{ expensiveMethod() }}</p> <!-- Runs twice -->
</template>
watch()
watch() explicitly watches a specific reactive value and runs a callback whenever it changes.
Basic Usage
<script setup>
import { ref, reactive, watch } from 'vue'
const count = ref(0)
const user = reactive({ name: 'Alice', age: 30 })
// 1. Watch a ref
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`)
})
// 2. Watch a specific property of reactive via getter function
watch(
() => user.name,
(newName, oldName) => {
console.log(`name: ${oldName} → ${newName}`)
}
)
// 3. Watch multiple sources at once
watch(
[count, () => user.name],
([newCount, newName], [oldCount, oldName]) => {
console.log('count or name changed')
}
)
</script>
watch Options
<script setup>
import { ref, watch } from 'vue'
const searchQuery = ref('')
const settings = ref({
theme: 'light',
language: 'en',
notifications: {
email: true,
sms: false,
},
})
// immediate: run once immediately when the component mounts
watch(
searchQuery,
(newQuery) => {
console.log('Search query:', newQuery)
// API call, etc.
},
{ immediate: true } // Runs once with the initial value
)
// deep: also detect changes in nested objects
watch(
settings,
(newSettings) => {
// Triggered even if settings.value.notifications.email changes
console.log('Settings changed:', newSettings)
localStorage.setItem('settings', JSON.stringify(newSettings))
},
{ deep: true }
)
// once: run only the first time (Vue 3.4+)
watch(
searchQuery,
(newQuery) => {
console.log('First search query entered:', newQuery)
},
{ once: true }
)
</script>
watch — Async Operations and Cleanup
<script setup>
import { ref, watch } from 'vue'
const userId = ref(1)
const userData = ref(null)
const loading = ref(false)
watch(userId, async (newId, oldId, onCleanup) => {
loading.value = true
userData.value = null
// Cancel previous request with AbortController
const controller = new AbortController()
// onCleanup: called before the next watch run or when the component unmounts
onCleanup(() => {
controller.abort()
console.log('Previous request cancelled')
})
try {
const res = await fetch(`/api/users/${newId}`, {
signal: controller.signal,
})
userData.value = await res.json()
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Request failed:', err)
}
} finally {
loading.value = false
}
})
</script>
Stopping a Watch
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
// watch returns a stop function
const stopWatch = watch(count, (newVal) => {
console.log('count:', newVal)
if (newVal >= 10) {
stopWatch() // Stop watching when count reaches 10
console.log('Watch stopped')
}
})
</script>
watchEffect()
watchEffect() automatically tracks every reactive value used inside the callback and re-runs whenever any of them change. Unlike watch, you don't specify what to watch.
Basic Usage
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
const name = ref('Alice')
// Auto-tracks both count and name
watchEffect(() => {
// Any reactive value used inside becomes a dependency automatically
console.log(`Current state: count=${count.value}, name=${name.value}`)
})
// Runs once immediately when the component mounts
// Re-runs whenever count or name changes
</script>
watchEffect — Cleanup Function
<script setup>
import { ref, watchEffect } from 'vue'
const query = ref('')
watchEffect((onCleanup) => {
const timer = setTimeout(() => {
if (query.value) {
console.log('API call:', query.value)
// fetch('/api/search?q=' + query.value)
}
}, 300) // Debounce 300ms
// Cancel the previous timer before the next run
onCleanup(() => {
clearTimeout(timer)
})
})
</script>
watch vs watchEffect Comparison
<script setup>
import { ref, watch, watchEffect } from 'vue'
const userId = ref(1)
const theme = ref('light')
// watch: explicit watching, access to oldValue, lazy by default
watch(userId, (newId, oldId) => {
console.log(`userId: ${oldId} → ${newId}`)
// Both newId and oldId are accessible
})
// watchEffect: auto-tracking, no oldValue, runs immediately
watchEffect(() => {
// Both userId and theme are automatically tracked
document.title = `User ${userId.value} | ${theme.value}`
// oldValue is not accessible
})
</script>
| Feature | watch | watchEffect |
|---|---|---|
| Watch target | Explicitly specified | Automatically tracked |
| Initial run | Not by default (needs immediate option) | Runs immediately |
| Access to oldValue | Yes | No |
| Async handling | Easy | Easy |
| Best for | Reacting to a specific value change | Synchronizing side effects |
Practical Example — Search Component (All APIs Combined)
<!-- SearchComponent.vue -->
<script setup>
import {
ref,
reactive,
computed,
watch,
watchEffect,
onMounted,
onUnmounted,
} from 'vue'
// ---- State ----
const query = ref('')
const results = ref([])
const loading = ref(false)
const error = ref(null)
const filter = reactive({
category: 'all',
sortBy: 'relevance',
minPrice: 0,
maxPrice: 100000,
})
// ---- Computed ----
const filteredResults = computed(() => {
let list = results.value
if (filter.category !== 'all') {
list = list.filter(item => item.category === filter.category)
}
if (filter.sortBy === 'price-asc') {
list = [...list].sort((a, b) => a.price - b.price)
} else if (filter.sortBy === 'price-desc') {
list = [...list].sort((a, b) => b.price - a.price)
}
return list.filter(
item => item.price >= filter.minPrice && item.price <= filter.maxPrice
)
})
const hasResults = computed(() => filteredResults.value.length > 0)
const resultCount = computed(() => `${filteredResults.value.length} results`)
// ---- Watch: debounced API call on query change ----
let searchTimer = null
watch(query, (newQuery) => {
clearTimeout(searchTimer)
if (!newQuery.trim()) {
results.value = []
return
}
loading.value = true
searchTimer = setTimeout(async () => {
try {
// const res = await fetch(`/api/search?q=${encodeURIComponent(newQuery)}`)
// results.value = await res.json()
// Simulation
await new Promise(r => setTimeout(r, 400))
results.value = [
{ id: 1, title: `${newQuery} - Item 1`, category: 'electronics', price: 29000 },
{ id: 2, title: `${newQuery} - Item 2`, category: 'books', price: 15000 },
{ id: 3, title: `${newQuery} - Item 3`, category: 'electronics', price: 75000 },
]
error.value = null
} catch (err) {
error.value = 'An error occurred while searching.'
} finally {
loading.value = false
}
}, 300)
})
// ---- watchEffect: sync URL query parameters ----
watchEffect(() => {
const params = new URLSearchParams()
if (query.value) params.set('q', query.value)
if (filter.category !== 'all') params.set('cat', filter.category)
// Update URL without adding to browser history
const newUrl = `${window.location.pathname}?${params.toString()}`
window.history.replaceState({}, '', newUrl)
})
// ---- Lifecycle ----
onMounted(() => {
// Restore initial search query from URL
const params = new URLSearchParams(window.location.search)
const q = params.get('q')
if (q) query.value = q
})
onUnmounted(() => {
clearTimeout(searchTimer)
})
// ---- Methods ----
const clearSearch = () => {
query.value = ''
results.value = []
}
const resetFilters = () => {
filter.category = 'all'
filter.sortBy = 'relevance'
filter.minPrice = 0
filter.maxPrice = 100000
}
</script>
<template>
<div class="search-container">
<!-- Search input -->
<div class="search-bar">
<input
v-model="query"
type="text"
placeholder="Enter a search query..."
class="search-input"
/>
<button v-if="query" @click="clearSearch" class="clear-btn">✕</button>
</div>
<!-- Filters -->
<div class="filters">
<select v-model="filter.category">
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="books">Books</option>
</select>
<select v-model="filter.sortBy">
<option value="relevance">Most Relevant</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
</select>
<button @click="resetFilters">Reset Filters</button>
</div>
<!-- Results -->
<div v-if="loading" class="loading">Searching...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="hasResults">
<p class="count">{{ resultCount }}</p>
<ul class="result-list">
<li v-for="item in filteredResults" :key="item.id" class="result-item">
<h3>{{ item.title }}</h3>
<span class="category">{{ item.category }}</span>
<span class="price">{{ item.price.toLocaleString() }} KRW</span>
</li>
</ul>
</div>
<div v-else-if="query" class="no-results">No results found.</div>
</div>
</template>
<style scoped>
.search-container { max-width: 600px; margin: 0 auto; padding: 1rem; }
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.search-input { flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.result-list { list-style: none; padding: 0; }
.result-item { border: 1px solid #eee; border-radius: 8px; padding: 1rem; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; }
.category { background: #e8f4f8; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
.price { font-weight: bold; color: #e44d26; }
</style>
Pro Tips
shallowRef and shallowReactive — Performance Optimization
<script setup>
import { shallowRef, shallowReactive } from 'vue'
// shallowRef: only the top-level .value is reactive (nested objects are not)
// Useful for large data sets or external library objects
const bigData = shallowRef({ items: new Array(10000).fill(0) })
// Replacing the whole value → triggers reactive update
bigData.value = { items: new Array(10000).fill(1) }
// Mutating internally → no reactive update (performance benefit)
bigData.value.items[0] = 99 // Screen does NOT update
// shallowReactive: only top-level properties are reactive
const state = shallowReactive({ count: 0, nested: { value: 0 } })
state.count++ // Reactive ✓
state.nested.value++ // NOT reactive ✗
</script>
Controlling the Execution Timing of computed and watch
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const count = ref(0)
// The flush option of watch
watch(count, (newVal) => {
// 'pre' (default): runs before DOM update
// 'post': runs after DOM update (lets you access the latest DOM)
// 'sync': runs synchronously and immediately (generally not recommended)
}, { flush: 'post' })
// nextTick: run code after the DOM has been updated
const updateAndRead = async () => {
count.value++
await nextTick()
// DOM is now updated
console.log('DOM update complete')
}
</script>
Custom Composable Pattern
// composables/useFetch.js — reusable data fetching logic
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(urlOrRef) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
watchEffect(async (onCleanup) => {
// toValue: handles ref, reactive, or a plain value uniformly
const url = toValue(urlOrRef)
if (!url) return
data.value = null
error.value = null
loading.value = true
const controller = new AbortController()
onCleanup(() => controller.abort())
try {
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (err) {
if (err.name !== 'AbortError') {
error.value = err.message
}
} finally {
loading.value = false
}
})
return { data, error, loading }
}
<!-- Usage example -->
<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
const userId = ref(1)
// Automatically re-fetches with the new URL when userId changes
const { data: user, loading, error } = useFetch(
() => `/api/users/${userId.value}`
)
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else-if="user">{{ user.name }}</div>
<button @click="userId++">Next User</button>
</template>
effectScope — Dispose Related Effects Together
import { effectScope, ref, watch, watchEffect } from 'vue'
// Group multiple watch/watchEffect calls into one scope for bulk disposal
const scope = effectScope()
scope.run(() => {
const count = ref(0)
watchEffect(() => console.log('count:', count.value))
watch(count, (n) => console.log('changed:', n))
})
// Stop all effects in the scope with a single call
scope.stop()
This pattern is especially useful when managing reactive effects in global state (such as Pinia stores) independently of a component's lifecycle.