Reactivity System & Composition API
Vue Reactivity System & Composition API Vue 3 introduced the Composition API, a powerful set of APIs that allows you to author Vue components using imported fun…
Vue Reactivity System & Composition API
Vue 3 introduced the Composition API, a powerful set of APIs that allows you to author Vue components using imported functions. Understanding Vue's reactivity system is key to building efficient applications.
The Reactivity System
Vue's reactivity system automatically tracks dependencies and updates the DOM when data changes. It uses Proxy objects to intercept property access and modifications.
ref() - Reactive References
ref() creates a reactive reference to a value. It works with primitives and objects, and requires `.value` to access the underlying value in JavaScript (but not in templates).
<script setup>
import { ref } from 'vue'
// Primitive values
const count = ref(0)
const message = ref('Hello Vue')
const isActive = ref(false)
// Accessing values
console.log(count.value) // 0
count.value++ // increment
// Arrays and objects
const todos = ref([
{ id: 1, text: 'Learn Vue', done: false },
{ id: 2, text: 'Build app', done: false },
])
const user = ref({
name: 'John Doe',
email: 'john@example.com',
age: 30,
})
// Updating nested properties
user.value.age = 31
todos.value[0].done = true
// Adding to array
todos.value.push({ id: 3, text: 'Deploy', done: false })
</script>
<template>
<div>
<!-- In templates, .value is not needed -->
<h1>{{ message }}</h1>
<p>Count: {{ count }}</p>
<button @click="count++">Increment</button>
<ul>
<li v-for="todo in todos" :key="todo.id">
<input type="checkbox" v-model="todo.done" />
{{ todo.text }}
</li>
</ul>
</div>
</template>reactive() - Reactive Objects
reactive() creates a reactive proxy for objects. Unlike ref(), it doesn't require `.value` and provides a more natural API for working with objects.
<script setup>
import { reactive } from 'vue'
// Create reactive object
const state = reactive({
count: 0,
message: 'Hello',
user: {
name: 'John',
email: 'john@example.com',
},
todos: [],
})
// Direct property access (no .value needed)
state.count++
state.message = 'Hello Vue'
state.user.name = 'Jane'
state.todos.push({ id: 1, text: 'Learn Vue' })
// Destructuring loses reactivity - use toRefs
import { toRefs } from 'vue'
const { count, message } = toRefs(state) // Now count and message are refs
// Or use toRef for single property
import { toRef } from 'vue'
const count = toRef(state, 'count')
</script>
<template>
<div>
<h1>{{ state.message }}</h1>
<p>Count: {{ state.count }}</p>
<button @click="state.count++">Increment</button>
</div>
</template>computed() - Computed Properties
Computed properties are cached reactive values that automatically update when their dependencies change. They are ideal for deriving state from existing reactive data.
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// Read-only computed
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
// Writable computed
const fullNameWritable = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(newValue) {
const [first, last] = newValue.split(' ')
firstName.value = first
lastName.value = last
},
})
// Complex computed with filtering and sorting
const todos = ref([
{ id: 1, text: 'Learn Vue', done: true, priority: 'high' },
{ id: 2, text: 'Build app', done: false, priority: 'high' },
{ id: 3, text: 'Deploy', done: false, priority: 'low' },
])
const activeTodos = computed(() => {
return todos.value.filter(todo => !todo.done)
})
const highPriorityTodos = computed(() => {
return todos.value
.filter(todo => todo.priority === 'high')
.sort((a, b) => a.text.localeCompare(b.text))
})
const todoStats = computed(() => ({
total: todos.value.length,
completed: todos.value.filter(t => t.done).length,
active: todos.value.filter(t => !t.done).length,
completionRate: (todos.value.filter(t => t.done).length / todos.value.length * 100).toFixed(1),
}))
</script>
<template>
<div>
<h1>{{ fullName }}</h1>
<input v-model="fullNameWritable" placeholder="Enter full name" />
<div>
<p>Total: {{ todoStats.total }}</p>
<p>Completed: {{ todoStats.completed }}</p>
<p>Active: {{ todoStats.active }}</p>
<p>Completion Rate: {{ todoStats.completionRate }}%</p>
</div>
</div>
</template>watch() & watchEffect()
Watchers allow you to perform side effects in response to reactive state changes. watch() is explicit about what to watch, while watchEffect() automatically tracks dependencies.
<script setup>
import { ref, watch, watchEffect } from 'vue'
const count = ref(0)
const searchQuery = ref('')
const user = ref({ name: 'John', age: 30 })
// watch() - explicit dependencies
watch(count, (newValue, oldValue) => {
console.log(`Count changed from ${oldValue} to ${newValue}`)
})
// Watch multiple sources
watch([count, searchQuery], ([newCount, newQuery], [oldCount, oldQuery]) => {
console.log('Count or query changed')
})
// Watch with immediate option
watch(
searchQuery,
async (newQuery) => {
if (newQuery.length > 2) {
const results = await searchAPI(newQuery)
console.log('Search results:', results)
}
},
{ immediate: true } // Run on mount
)
// Deep watch for nested objects
watch(
user,
(newUser) => {
console.log('User changed:', newUser)
},
{ deep: true } // Watch nested properties
)
// watchEffect() - automatic dependency tracking
watchEffect(() => {
// Automatically tracks count and searchQuery
console.log(`Search '${searchQuery.value}' with count ${count.value}`)
})
// watchEffect with cleanup
watchEffect((onCleanup) => {
const controller = new AbortController()
fetch(`/api/search?q=${searchQuery.value}`, {
signal: controller.signal,
})
.then(res => res.json())
.then(data => console.log(data))
onCleanup(() => {
// Cancel pending requests
controller.abort()
})
})
// Stop watching
const stop = watch(count, () => {})
stop() // Stop watching
</script>Composition API Fundamentals
setup() Function
The setup() function is the entry point for using the Composition API. The <script setup> syntax is syntactic sugar that makes it more ergonomic.
// Traditional setup() function
export default {
setup(props, context) {
const count = ref(0)
const increment = () => count.value++
// Must explicitly return what to expose to template
return {
count,
increment,
}
},
}
// <script setup> - recommended
<script setup>
import { ref } from 'vue'
// Everything is automatically exposed to template
const count = ref(0)
const increment = () => count.value++
// Props
const props = defineProps({
title: String,
initialCount: { type: Number, default: 0 },
})
// Emits
const emit = defineEmits(['update', 'delete'])
const handleUpdate = () => {
emit('update', count.value)
}
</script>
<template>
<div>
<h2>{{ props.title }}</h2>
<p>Count: {{ count }}</p>
<button @click="increment">+</button>
<button @click="handleUpdate">Update Parent</button>
</div>
</template>Lifecycle Hooks
Vue provides lifecycle hooks that allow you to run code at specific stages of a component's lifecycle.
<script setup>
import {
ref,
onMounted,
onUpdated,
onUnmounted,
onBeforeMount,
onBeforeUpdate,
onBeforeUnmount,
} from 'vue'
const data = ref(null)
const timer = ref(null)
// Before component is mounted
onBeforeMount(() => {
console.log('Component is about to mount')
})
// After component is mounted
onMounted(() => {
console.log('Component mounted')
// Fetch data
fetch('/api/data')
.then(res => res.json())
.then(result => {
data.value = result
})
// Start timer
timer.value = setInterval(() => {
console.log('Timer tick')
}, 1000)
})
// After component updates
onUpdated(() => {
console.log('Component updated')
})
// Before component unmounts
onBeforeUnmount(() => {
console.log('Component about to unmount')
})
// After component unmounts
onUnmounted(() => {
console.log('Component unmounted')
// Cleanup
if (timer.value) {
clearInterval(timer.value)
}
})
</script>Composables - Reusable Logic
Composables are functions that leverage Vue's Composition API to encapsulate and reuse stateful logic. They are similar to React custom hooks.
// composables/useMouse.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 }
}
// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
watchEffect(async () => {
// toValue() normalizes refs and plain values
const urlValue = toValue(url)
loading.value = true
error.value = null
try {
const response = await fetch(urlValue)
if (!response.ok) throw new Error(response.statusText)
data.value = await response.json()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
return { data, error, loading }
}
// composables/useLocalStorage.js
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const value = ref(stored ? JSON.parse(stored) : defaultValue)
watch(
value,
(newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
},
{ deep: true }
)
return value
}
// Usage in component
<script setup>
import { useMouse } from './composables/useMouse'
import { useFetch } from './composables/useFetch'
import { useLocalStorage } from './composables/useLocalStorage'
const { x, y } = useMouse()
const userId = ref(1)
const { data: user, loading, error } = useFetch(() => `/api/users/${userId.value}`)
const theme = useLocalStorage('theme', 'light')
</script>
<template>
<div>
<p>Mouse: {{ x }}, {{ y }}</p>
<p v-if="loading">Loading...</p>
<p v-else-if="error">Error: {{ error.message }}</p>
<div v-else>User: {{ user?.name }}</div>
<button @click="theme = theme === 'light' ? 'dark' : 'light'">
Toggle Theme ({{ theme }})
</button>
</div>
</template>Reactive Utilities
shallowRef() & shallowReactive()
For performance optimization, use shallow variants when you only need reactivity at the root level.
import { shallowRef, shallowReactive, triggerRef } from 'vue'
// shallowRef - only .value is reactive
const state = shallowRef({ count: 0 })
state.value.count++ // Won't trigger update
state.value = { count: 1 } // Will trigger update
// Force trigger update
triggerRef(state)
// shallowReactive - only root-level properties are reactive
const stateReactive = shallowReactive({
count: 0,
nested: { value: 0 },
})
stateReactive.count++ // Will trigger update
stateReactive.nested.value++ // Won't trigger update
stateReactive.nested = { value: 1 } // Will trigger updatereadonly() & isRef()
import { ref, readonly, isRef, isReactive } from 'vue'
const original = ref(0)
const copy = readonly(original)
copy.value++ // Warning: cannot modify readonly ref
original.value++ // OK, and copy reflects the change
// Type checking
const count = ref(0)
const state = reactive({})
console.log(isRef(count)) // true
console.log(isRef(state)) // false
console.log(isReactive(state)) // true
console.log(isReactive(count)) // falseBest Practices
Use ref() for primitives, reactive() for objects
Prefer computed() over watch() when deriving values
Extract reusable logic into composables
Use <script setup> for cleaner syntax
Clean up side effects in onUnmounted()