Vue 3 Composition API: Complete Guide to Mastering Reactivity
Master Vue 3 Composition API with this practical guide. Learn ref, reactive, computed, watch, and composables to build performant Vue applications.

The Vue 3 Composition API represents a major evolution in how Vue components are structured. This approach organizes code by feature rather than by option, making it easier to reuse logic and maintain complex applications.
This guide assumes basic knowledge of Vue.js. Examples use the <script setup> syntax introduced in Vue 3.2, which is now the recommended approach.
Understanding Reactivity with ref and reactive
Reactivity lies at the heart of Vue 3. Two main primitives create reactive data: ref for primitive values and reactive for complex objects.
The ref function creates a reactive reference that wraps a value. To access or modify this value in the script, the .value property must be used. In the template, Vue automatically unwraps this property.
<script setup>
import { ref } from 'vue'
// Create a ref with initial value 0
const count = ref(0)
// Function that increments the counter
// Note: .value is required in the script
const increment = () => {
count.value++
}
// Reset function
const reset = () => {
count.value = 0
}
</script>
<template>
<!-- In the template, no .value needed -->
<div class="counter">
<p>Counter: {{ count }}</p>
<button @click="increment">+1</button>
<button @click="reset">Reset</button>
</div>
</template>For objects with multiple related properties, reactive offers a more natural syntax without needing .value.
<script setup>
import { reactive } from 'vue'
// reactive for complex objects
// All properties are automatically reactive
const user = reactive({
name: 'Jane Smith',
email: 'jane@example.com',
preferences: {
theme: 'dark',
notifications: true
}
})
// Direct property modification (no .value)
const updateTheme = (newTheme) => {
user.preferences.theme = newTheme
}
// Warning: do not reassign the entire object
// user = { ... } would break reactivity
</script>
<template>
<div>
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
<p>Theme: {{ user.preferences.theme }}</p>
</div>
</template>The general rule: use ref for primitive values (string, number, boolean) and reactive for structured objects.
Computed Properties with computed
Computed properties derive values from reactive state. They are cached and only recalculate when their dependencies change, making them highly performant.
<script setup>
import { ref, computed } from 'vue'
// Product list
const products = ref([
{ id: 1, name: 'Laptop', price: 999, inStock: true },
{ id: 2, name: 'Mouse', price: 29, inStock: true },
{ id: 3, name: 'Keyboard', price: 79, inStock: false },
{ id: 4, name: 'Monitor', price: 299, inStock: true }
])
// Active filter
const showOnlyInStock = ref(false)
// computed: automatic filtering based on toggle
// Only recalculates if products or showOnlyInStock changes
const filteredProducts = computed(() => {
if (showOnlyInStock.value) {
return products.value.filter(p => p.inStock)
}
return products.value
})
// computed: total calculation with caching
const totalValue = computed(() => {
return filteredProducts.value.reduce((sum, p) => sum + p.price, 0)
})
// computed: number of displayed items
const productCount = computed(() => filteredProducts.value.length)
</script>
<template>
<div>
<label>
<input type="checkbox" v-model="showOnlyInStock" />
Show only in-stock products
</label>
<p>{{ productCount }} products - Total: ${{ totalValue }}</p>
<ul>
<li v-for="product in filteredProducts" :key="product.id">
{{ product.name }} - ${{ product.price }}
</li>
</ul>
</div>
</template>Computed properties can also be writable with a getter and setter, useful for bidirectional transformations.
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// computed with getter AND setter
const fullName = computed({
// Read: combine first and last name
get() {
return `${firstName.value} ${lastName.value}`
},
// Write: split the string into first/last name
set(newValue) {
const parts = newValue.split(' ')
firstName.value = parts[0] || ''
lastName.value = parts.slice(1).join(' ') || ''
}
})
</script>
<template>
<!-- Modifying fullName updates firstName and lastName -->
<input v-model="fullName" placeholder="Full name" />
<p>First name: {{ firstName }}</p>
<p>Last name: {{ lastName }}</p>
</template>Watchers with watch and watchEffect
Watchers execute side effects in response to data changes. Vue 3 offers two approaches: watch for precise control and watchEffect for automatic tracking.
watch offers precise control over dependencies and provides both old and new values. watchEffect is simpler when all reactive dependencies used should trigger the effect.
<script setup>
import { ref, watch, watchEffect } from 'vue'
const searchQuery = ref('')
const searchResults = ref([])
const isLoading = ref(false)
// watch: observes a specific source
// Provides oldValue and newValue, allows debouncing
watch(searchQuery, async (newQuery, oldQuery) => {
console.log(`Search changed from "${oldQuery}" to "${newQuery}"`)
// Don't search if less than 3 characters
if (newQuery.length < 3) {
searchResults.value = []
return
}
isLoading.value = true
try {
// Simulated API call
const response = await fetch(`/api/search?q=${newQuery}`)
searchResults.value = await response.json()
} finally {
isLoading.value = false
}
}, {
// Watcher options
debounce: 300, // Wait 300ms after last keystroke
immediate: false // Don't execute immediately
})
// watchEffect: automatic dependency tracking
// Runs immediately and on every change
watchEffect(() => {
// All refs used here are automatically tracked
console.log(`Current state: ${searchResults.value.length} results`)
console.log(`Loading: ${isLoading.value}`)
})
</script>
<template>
<div>
<input v-model="searchQuery" placeholder="Search..." />
<p v-if="isLoading">Loading...</p>
<ul v-else>
<li v-for="result in searchResults" :key="result.id">
{{ result.title }}
</li>
</ul>
</div>
</template>To observe nested objects or arrays, the deep option is necessary with watch.
<script setup>
import { reactive, watch } from 'vue'
const settings = reactive({
display: {
theme: 'light',
fontSize: 14
},
notifications: {
email: true,
push: false
}
})
// deep: true to observe nested changes
watch(
() => settings.display,
(newDisplay) => {
console.log('Display settings changed:', newDisplay)
// Save to localStorage for example
localStorage.setItem('display', JSON.stringify(newDisplay))
},
{ deep: true }
)
</script>Ready to ace your Vue.js / Nuxt.js interviews?
Practice with our interactive simulators, flashcards, and technical tests.
Creating Reusable Composables
Composables are functions that encapsulate reusable reactive logic. This approach is one of the major strengths of the Composition API for sharing code between components.
import { ref, watchEffect, toValue } from 'vue'
// Composable for HTTP requests
// Automatically handles loading, errors, and refetching
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
// Reusable fetch function
async function fetchData() {
isLoading.value = true
error.value = null
try {
// toValue() allows accepting a ref or a value
const response = await fetch(toValue(url))
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
data.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
isLoading.value = false
}
}
// watchEffect for automatic refetch if url is a ref
watchEffect(() => {
fetchData()
})
// Expose state and methods
return {
data,
error,
isLoading,
refetch: fetchData
}
}This composable can then be used declaratively in any component.
<script setup>
import { useFetch } from '@/composables/useFetch'
// Simple composable usage
const { data: users, isLoading, error, refetch } = useFetch('/api/users')
</script>
<template>
<div>
<button @click="refetch" :disabled="isLoading">
Refresh
</button>
<p v-if="isLoading">Loading users...</p>
<p v-else-if="error" class="error">Error: {{ error }}</p>
<ul v-else-if="users">
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>Here is another example of a composable for managing form state with validation.
import { reactive, computed } from 'vue'
// Form management composable
export function useForm(initialValues, validationRules) {
// Form state
const form = reactive({
values: { ...initialValues },
errors: {},
touched: {}
})
// Validate a specific field
const validateField = (field) => {
const rules = validationRules[field]
if (!rules) return true
for (const rule of rules) {
const result = rule(form.values[field])
if (result !== true) {
form.errors[field] = result
return false
}
}
form.errors[field] = null
return true
}
// Validate entire form
const validate = () => {
let isValid = true
for (const field in validationRules) {
if (!validateField(field)) {
isValid = false
}
}
return isValid
}
// computed: form is valid if no errors
const isValid = computed(() => {
return Object.values(form.errors).every(e => !e)
})
// Mark a field as touched (to display errors)
const touch = (field) => {
form.touched[field] = true
validateField(field)
}
// Reset the form
const reset = () => {
form.values = { ...initialValues }
form.errors = {}
form.touched = {}
}
return {
form,
isValid,
validate,
validateField,
touch,
reset
}
}Lifecycle Hook Management
The Composition API provides lifecycle hooks as functions to call within setup. These hooks execute code at specific moments in the component's lifecycle.
Always clean up side effects (timers, event listeners, subscriptions) in onUnmounted to avoid memory leaks.
<script setup>
import {
ref,
onMounted,
onUnmounted,
onBeforeUpdate,
onUpdated
} from 'vue'
const windowWidth = ref(window.innerWidth)
const updateCount = ref(0)
// Handler for resize
const handleResize = () => {
windowWidth.value = window.innerWidth
}
// onMounted: component inserted into DOM
// Ideal for initial API calls and event listeners
onMounted(() => {
console.log('Component mounted')
window.addEventListener('resize', handleResize)
// Example: initialize a third-party library
// chart = new Chart(chartRef.value, config)
})
// onUnmounted: component removed from DOM
// CRITICAL: clean up all side effects
onUnmounted(() => {
console.log('Component unmounted')
window.removeEventListener('resize', handleResize)
// Clean up subscriptions, timers, etc.
// chart?.destroy()
})
// onBeforeUpdate: before DOM update
onBeforeUpdate(() => {
console.log('Before update')
})
// onUpdated: after DOM update
onUpdated(() => {
updateCount.value++
console.log(`DOM updated (${updateCount.value} times)`)
})
</script>
<template>
<div>
<p>Window width: {{ windowWidth }}px</p>
<p>DOM updates: {{ updateCount }}</p>
</div>
</template>Template Refs and DOM Access
Template refs provide direct access to DOM elements or child component instances. This remains useful for cases where direct manipulation is necessary.
<script setup>
import { ref, onMounted } from 'vue'
// ref for the DOM element
// Same name as the ref attribute in the template
const inputRef = ref(null)
// Function to focus the input
const focusInput = () => {
// Access to the native DOM element
inputRef.value?.focus()
}
// Auto-focus on mount
onMounted(() => {
focusInput()
})
// Expose the method to parent if needed
defineExpose({
focus: focusInput
})
</script>
<template>
<div>
<!-- The ref attribute binds the element to inputRef -->
<input ref="inputRef" type="text" placeholder="Auto-focused" />
<button @click="focusInput">Refocus</button>
</div>
</template>Parent-Child Communication with Props and Emits
The Composition API modernizes props and events declaration with defineProps and defineEmits, offering better TypeScript integration.
<script setup>
// defineProps: declares expected props
// With default values via withDefaults
const props = withDefaults(defineProps<{
title: string
count?: number
items?: string[]
}>(), {
count: 0,
items: () => []
})
// defineEmits: declares emitted events
// Precise payload typing
const emit = defineEmits<{
(e: 'update', value: number): void
(e: 'submit', data: { title: string; items: string[] }): void
}>()
// Function that emits an event
const handleSubmit = () => {
emit('submit', {
title: props.title,
items: props.items
})
}
const increment = () => {
emit('update', props.count + 1)
}
</script>
<template>
<div class="card">
<h3>{{ title }}</h3>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
<button @click="handleSubmit">Submit</button>
</div>
</template><script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const currentCount = ref(0)
const items = ref(['Item 1', 'Item 2'])
// Handler for update event
const onUpdate = (newValue) => {
currentCount.value = newValue
}
// Handler for submit event
const onSubmit = (data) => {
console.log('Data submitted:', data)
}
</script>
<template>
<ChildComponent
title="My Component"
:count="currentCount"
:items="items"
@update="onUpdate"
@submit="onSubmit"
/>
</template>Ready to ace your Vue.js / Nuxt.js interviews?
Practice with our interactive simulators, flashcards, and technical tests.
Provide and Inject for Dependency Injection
To share data between distant components without prop drilling, Vue 3 offers provide and inject.
<script setup>
import { provide, ref, readonly } from 'vue'
// Global theme state
const theme = ref('light')
// Function to toggle theme
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
// provide: makes available to descendants
// readonly prevents direct modification by children
provide('theme', readonly(theme))
provide('toggleTheme', toggleTheme)
</script>
<template>
<div :class="theme">
<slot />
</div>
</template><script setup>
import { inject } from 'vue'
// inject: retrieves value provided by an ancestor
// Default value if not found
const theme = inject('theme', 'light')
const toggleTheme = inject('toggleTheme', () => {})
</script>
<template>
<div>
<p>Current theme: {{ theme }}</p>
<button @click="toggleTheme">
Switch to {{ theme === 'light' ? 'dark' : 'light' }}
</button>
</div>
</template>Conclusion
The Vue 3 Composition API offers a powerful and flexible approach to organizing Vue application code. Key concepts to remember:
- ✅ ref for primitive values, reactive for complex objects
- ✅ computed for derived values with automatic caching
- ✅ watch and watchEffect for reactive side effects
- ✅ Composables to extract and reuse logic between components
- ✅ Functional lifecycle hooks (
onMounted,onUnmounted, etc.) - ✅ provide/inject for dependency injection without prop drilling
This approach facilitates creating maintainable and testable applications while offering excellent TypeScript integration. The next step involves exploring advanced patterns like async composables and integration with Pinia for global state management.
Start practicing!
Test your knowledge with our interview simulators and technical tests.
Tags
Share
Related articles

Essential Vue.js Interview Questions: 25 Questions to Land the Job
Prepare for Vue.js interviews with these 25 essential questions. From reactivity to composables, master the key concepts to ace your next interview.

Nuxt 3: SSR and Static Generation Complete Guide
Master SSR and static generation with Nuxt 3. From useFetch to route rules, learn how to optimize performance for your Vue.js applications.