Introduction to the Composition API
The Composition API is a set of APIs introduced in Vue 3 that provides a more flexible way to organize and reuse code in Vue components. It addresses limitations of the Options API, particularly for complex components where related code ends up split across different options.
Real-world analogy: If the Options API is like sorting items into pre-defined containers (data, methods, computed, etc.), the Composition API is like organizing items by their relationships. It's similar to the difference between sorting books by size/color versus grouping them by topic or author.
Options API vs Composition API
Let's compare a simple component implemented with both APIs to understand the differences:
Options API
<script>
export default {
// State is grouped in data
data() {
return {
count: 0,
name: 'John',
isActive: true
}
},
// Methods grouped together
methods: {
increment() {
this.count++
},
updateName(newName) {
this.name = newName
},
toggleActive() {
this.isActive = !this.isActive
}
},
// Computed properties grouped together
computed: {
doubleCount() {
return this.count * 2
},
displayName() {
return `User: ${this.name}`
}
},
// Lifecycle hooks scattered
mounted() {
console.log('Component mounted')
}
}
</script>
Composition API
<script setup>
import { ref, computed, onMounted } from 'vue'
// Counter feature
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
// User feature
const name = ref('John')
const displayName = computed(() => `User: ${name.value}`)
function updateName(newName) {
name.value = newName
}
// Activity feature
const isActive = ref(true)
function toggleActive() {
isActive.value = !isActive.value
}
// Lifecycle hook
onMounted(() => {
console.log('Component mounted')
})
</script>
Key Differences and Advantages
- Code Organization: The Composition API groups code by logical concern rather than option type, making it easier to understand and maintain related features.
- Reusability: The Composition API makes it easier to extract and reuse logic across components.
- TypeScript Support: The Composition API provides better TypeScript typing compared to the Options API's this-based approach.
- Reactivity: The Composition API provides more direct access to Vue's reactivity system.
- Tree-Shaking: The Composition API's imported functions allow for better tree-shaking in the final bundle.
Important: The Composition API doesn't replace the Options API – both are fully supported in Vue 3. You can use either one or even mix them in the same project based on your preference and needs.
setup Function and script setup Syntax
There are two main ways to use the Composition API in Vue 3:
1. The setup() Function
The setup() function runs before the component instance is created, replacing the role of data, computed,
methods, watch, and lifecycle hooks from the Options API:
<script>
import { ref, computed, onMounted } from 'vue'
export default {
props: {
initialCount: Number
},
setup(props, context) {
// State
const count = ref(props.initialCount || 0)
// Computed property
const doubleCount = computed(() => count.value * 2)
// Method
function increment() {
count.value++
}
// Lifecycle hook
onMounted(() => {
console.log('Component mounted')
})
// Expose to the template (everything returned is available)
return {
count,
doubleCount,
increment
}
}
}
</script>
2. The <script setup> Syntax (Recommended)
Vue 3.2 introduced a simplified syntax using <script setup>, which is now the recommended approach:
<script setup>
import { ref, computed, onMounted } from 'vue'
// Props are declared with defineProps macro
const props = defineProps({
initialCount: Number
})
// State
const count = ref(props.initialCount || 0)
// Computed property
const doubleCount = computed(() => count.value * 2)
// Method
function increment() {
count.value++
}
// Lifecycle hook
onMounted(() => {
console.log('Component mounted')
})
// No need to return anything - all top-level bindings
// are automatically exposed to the template
</script>
Advantages of <script setup>:
- More concise code with less boilerplate
- Ability to declare props and emits with compile-time macros
- Better runtime performance (no need for a separate function called for each component instance)
- Better IDE type-inference and completion
- All variables/functions are automatically available in template
Reactivity Fundamentals
At the core of the Composition API is Vue's reactivity system, which allows the UI to automatically update when the underlying data changes.
Reactive References with ref()
ref() creates a reactive reference for a value of any type. The returned object has a single property .value
that holds the inner value.
import { ref } from 'vue'
// Primitive values
const count = ref(0)
const name = ref('John')
const isActive = ref(true)
// Accessing and modifying
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
// Complex values
const user = ref({
name: 'John',
age: 30
})
// Accessing and modifying
console.log(user.value.name) // 'John'
user.value.age = 31
Important: Inside templates, the .value property is automatically unwrapped for convenience:
<template>
<div>
<p>Count: {{ count }}</p> <!-- No .value needed in templates -->
<p>User: {{ user.name }}</p> <!-- Objects are also unwrapped automatically -->
<button @click="increment">Increment</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const user = ref({ name: 'John', age: 30 })
function increment() {
count.value++ // .value is required in JavaScript
}
</script>
Real-world analogy: A ref is like a special container that notifies Vue when its contents change.
Think of it as a display case in a museum – the case itself stays the same, but when you swap the item inside, sensors
detect the change and update related information displays.
Reactive Objects with reactive()
reactive() makes an entire object reactive. Unlike ref, you don't need to use .value
to access or modify properties:
import { reactive } from 'vue'
const user = reactive({
name: 'John',
age: 30,
address: {
city: 'New York',
zip: '10001'
}
})
// Direct access (no .value needed)
console.log(user.name) // 'John'
user.age = 31
user.address.city = 'Boston'
ref vs. reactive: When to Use Each
Use ref when
- Working with primitive values (strings, numbers, booleans)
- You need to pass reactive values to functions
- You want the flexibility to reassign the entire value
- You need to create reactive references to props
Use reactive when
- Working with objects or arrays exclusively
- You don't need to replace the entire object, just modify its properties
- You want a more natural syntax for object manipulation
- You're working with deeply nested objects
Common practice: Many Vue developers use ref for nearly everything to maintain consistency
and avoid the limitations of reactive (like losing reactivity when objects are destructured).
Reactivity Caveats
There are some important edge cases to be aware of:
1. Destructuring Reactive Objects: When you destructure a reactive object, the connection to the original
reactive object is lost:
const user = reactive({ name: 'John', age: 30 })
// This breaks reactivity!
const { name, age } = user
// These won't update the reactive user object
name = 'Jane'
age = 31
Solution: Use toRefs or toRef to maintain reactivity:
import { reactive, toRefs, toRef } from 'vue'
const user = reactive({ name: 'John', age: 30 })
// Keep reactivity with toRefs (for multiple properties)
const { name, age } = toRefs(user)
name.value = 'Jane' // This updates user.name!
// Or use toRef for a single property
const name = toRef(user, 'name')
name.value = 'Jane' // This updates user.name!
2. Collection Types: Vue cannot detect property addition or deletion for objects:
const user = reactive({ name: 'John' })
// This won't trigger updates in templates!
user.age = 30
Solution: Use the spread operator to create a new object or use Vue's utilities:
import { reactive } from 'vue'
let user = reactive({ name: 'John' })
// Method 1: Create a new reactive object
user = reactive({ ...user, age: 30 })
// Method 2: Use Object.assign with reactive objects
Object.assign(user, { age: 30 })
Computed Properties
Computed properties allow you to create derived state that updates automatically when its dependencies change.
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// Simple computed property
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
console.log(fullName.value) // 'John Doe'
// Changing dependency automatically updates computed value
firstName.value = 'Jane'
console.log(fullName.value) // 'Jane Doe'
Writable Computed Properties
Computed properties can also have getters and setters:
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed({
// getter
get() {
return `${firstName.value} ${lastName.value}`
},
// setter
set(newValue) {
const names = newValue.split(' ')
firstName.value = names[0]
lastName.value = names[names.length - 1]
}
})
console.log(fullName.value) // 'John Doe'
// Setting the computed property
fullName.value = 'Jane Smith'
console.log(firstName.value) // 'Jane'
console.log(lastName.value) // 'Smith'
Real-world use case: Writable computed properties are useful for creating derived values that can be manipulated, like formatted inputs (e.g., phone numbers, currencies) or complex state that affects multiple underlying values.
Watchers
Watchers allow you to perform side effects in response to changes in reactive state.
Basic watch
import { ref, watch } from 'vue'
const question = ref('')
const answer = ref('')
// Watch for changes to question
watch(question, async (newQuestion, oldQuestion) => {
if (newQuestion.trim() !== '') {
answer.value = 'Thinking...'
try {
const response = await fetch(`/api/answer?q=${encodeURIComponent(newQuestion)}`)
const data = await response.json()
answer.value = data.answer
} catch (error) {
answer.value = 'Error! Could not reach the API.'
}
} else {
answer.value = ''
}
})
Watching Multiple Sources
import { ref, watch } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// Watch multiple sources
watch([firstName, lastName], ([newFirstName, newLastName], [oldFirstName, oldLastName]) => {
console.log(`Name changed from ${oldFirstName} ${oldLastName} to ${newFirstName} ${newLastName}`)
})
Deep Watchers
import { reactive, watch } from 'vue'
const user = reactive({
name: 'John',
profile: {
age: 30,
preferences: {
newsletter: true,
theme: 'dark'
}
}
})
// Deep watch (detects nested property changes)
watch(user, (newValue, oldValue) => {
console.log('User object changed:', newValue)
}, { deep: true })
// This will trigger the watcher
user.profile.preferences.theme = 'light'
Immediate Watchers
import { ref, watch } from 'vue'
const searchQuery = ref('vue')
const results = ref([])
// Run immediately and on every change
watch(searchQuery, async (newQuery) => {
if (newQuery.trim()) {
const response = await fetch(`/api/search?q=${encodeURIComponent(newQuery)}`)
results.value = await response.json()
} else {
results.value = []
}
}, { immediate: true })
watchEffect for Automatic Dependency Tracking
watchEffect runs immediately and automatically tracks all reactive dependencies used within it,
re-running whenever any of them change:
import { ref, watchEffect } from 'vue'
const userId = ref(1)
const userData = ref(null)
// Automatically tracks dependencies (userId in this case)
watchEffect(async () => {
if (userId.value) {
const response = await fetch(`/api/users/${userId.value}`)
userData.value = await response.json()
} else {
userData.value = null
}
})
// Later, changing userId will trigger the effect again
userId.value = 2
Real-world use case: watchEffect is particularly useful for side effects that depend on
multiple reactive values, like data fetching, caching, or syncing to local storage.
Stopping Watchers
Both watch and watchEffect return a function that stops the watcher when called:
const stopWatch = watch(source, callback)
// Later, when no longer needed
stopWatch()
const stopEffect = watchEffect(() => {
// ...
})
// Later, when no longer needed
stopEffect()
Lifecycle Hooks
The Composition API provides equivalents for all lifecycle hooks from the Options API, prefixed with "on":
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onErrorCaptured
} from 'vue'
export default {
setup() {
// created is replaced by the setup function itself
console.log('Component instance created')
onBeforeMount(() => {
console.log('Before mounting DOM')
})
onMounted(() => {
console.log('DOM mounted')
// Access DOM elements, initialize libraries, etc.
})
onBeforeUpdate(() => {
console.log('Before re-rendering due to data change')
})
onUpdated(() => {
console.log('Re-rendered after data change')
})
onBeforeUnmount(() => {
console.log('Before component unmount')
// Clean up resources
})
onUnmounted(() => {
console.log('Component unmounted')
})
onErrorCaptured((err, instance, info) => {
console.error('Error captured:', err)
// Return false to prevent error propagation
return false
})
// Additional Vue 3 hooks
// Triggered when a component inside is toggled
onActivated(() => {
console.log('Component activated from cache')
})
onDeactivated(() => {
console.log('Component deactivated to cache')
})
// For debugging and performance tracking
onRenderTracked((event) => {
console.log('Dependency tracked:', event)
})
onRenderTriggered((event) => {
console.log('Re-render triggered by:', event)
})
// Rest of setup...
}
}
Note: There are no Composition API equivalents for beforeCreate and created
because the setup() function itself serves this purpose. Any code at the top level of setup()
or <script setup> is executed during the component's creation phase.
Template Refs
Template refs provide a way to access underlying DOM elements or child component instances directly.
<template>
<div>
<input ref="inputRef">
<button @click="focusInput">Focus Input</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// Create a ref with the same name as the template ref
const inputRef = ref(null)
// Access the element in a lifecycle hook
onMounted(() => {
// inputRef.value now points to the actual DOM element
inputRef.value.focus()
})
function focusInput() {
inputRef.value.focus()
}
</script>
Refs on Components
When used on a component, the ref will point to the component instance:
<template>
<ChildComponent ref="childRef" />
<button @click="callChildMethod">Call Child Method</button>
</template>
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const childRef = ref(null)
function callChildMethod() {
// Access public methods on the child component
childRef.value.somePublicMethod()
}
</script>
v-for with Refs
When using ref with v-for, you'll need an array to store the elements:
<template>
<ul>
<li v-for="(item, index) in items" :key="item.id" :ref="el => itemRefs[index] = el">
{{ item.text }}
</li>
</ul>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
const items = ref([
{ id: 1, text: 'Item 1' },
{ id: 2, text: 'Item 2' },
{ id: 3, text: 'Item 3' }
])
// Array to store element references
const itemRefs = reactive([])
onMounted(() => {
console.log(itemRefs) // Array of li elements
})
</script>
Composables: Reusing Logic
One of the main benefits of the Composition API is the ability to extract and reuse logic across components through "composable" functions.
Real-world analogy: Think of composables like kitchen appliances. Each appliance (blender, mixer, etc.) handles a specific task and can be used in many different recipes. Similarly, composables are reusable pieces of logic that can be combined to build complex functionality.
Creating a Composable Function
A composable is a function that leverages Vue's Composition API to encapsulate and reuse stateful logic:
// useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0, step = 1) {
// State
const count = ref(initialValue)
// Computed
const doubleCount = computed(() => count.value * 2)
// Methods
function increment() {
count.value += step
}
function decrement() {
count.value -= step
}
function reset() {
count.value = initialValue
}
// Return everything needed by components
return {
count,
doubleCount,
increment,
decrement,
reset
}
}
Using a Composable in a Component
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">Increment</button>
<button @click="decrement">Decrement</button>
<button @click="reset">Reset</button>
</div>
</template>
<script setup>
import { useCounter } from './composables/useCounter'
// Use the composable
const { count, doubleCount, increment, decrement, reset } = useCounter(10, 2)
</script>
Another Example: Fetch Data Composable
// useFetch.js
import { ref, watchEffect, toValue, isRef } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
async function fetchData() {
loading.value = true
error.value = null
try {
// Handle both ref and direct string
const response = await fetch(toValue(url))
if (!response.ok) {
throw new Error(`Network error: ${response.status}`)
}
data.value = await response.json()
} catch (err) {
error.value = err.message || 'Unknown error'
} finally {
loading.value = false
}
}
// If url is a ref, react to changes, otherwise just fetch once
if (isRef(url)) {
watchEffect(fetchData)
} else {
fetchData()
}
// Allow manual refresh
function refresh() {
return fetchData()
}
return { data, error, loading, refresh }
}
Using the Fetch Composable
<template>
<div>
<select v-model="selectedId">
<option value="1">User 1</option>
<option value="2">User 2</option>
<option value="3">User 3</option>
</select>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else-if="data">
<h2>{{ data.name }}</h2>
<p>Email: {{ data.email }}</p>
</div>
<button @click="refresh">Refresh</button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useFetch } from './composables/useFetch'
const selectedId = ref('1')
// Create a computed URL based on the selected ID
const userUrl = computed(() => `https://jsonplaceholder.typicode.com/users/${selectedId.value}`)
// Use the fetch composable with a reactive URL
const { data, error, loading, refresh } = useFetch(userUrl)
</script>
Composables vs. Mixins
Vue 2 used mixins for code reuse, but they had several drawbacks that composables address:
Mixins (Vue 2)
- Unclear source of properties (which mixin provided what)
- Naming conflicts between mixins
- Implicit dependency relationships
- Limited customization
Composables (Vue 3)
- Explicit imports make the source clear
- Variable renaming solves naming conflicts
- Clear dependency chain
- More customizable through function parameters
Complete Example: Task Manager with Composition API
Let's bring all these concepts together with a complete example of a task management application:
1. Task Management Composable
// composables/useTasks.js
import { ref, computed } from 'vue'
export function useTasks() {
// State
const tasks = ref([])
const filter = ref('all') // all, active, completed
// Computed
const filteredTasks = computed(() => {
switch (filter.value) {
case 'active':
return tasks.value.filter(task => !task.completed)
case 'completed':
return tasks.value.filter(task => task.completed)
default:
return tasks.value
}
})
const remainingCount = computed(() =>
tasks.value.filter(task => !task.completed).length
)
// Methods
function addTask(text) {
if (text.trim()) {
tasks.value.push({
id: Date.now(),
text,
completed: false
})
}
}
function removeTask(id) {
tasks.value = tasks.value.filter(task => task.id !== id)
}
function toggleTask(id) {
const task = tasks.value.find(task => task.id === id)
if (task) {
task.completed = !task.completed
}
}
function updateTaskText(id, text) {
const task = tasks.value.find(task => task.id === id)
if (task && text.trim()) {
task.text = text
}
}
function setFilter(newFilter) {
filter.value = newFilter
}
function clearCompleted() {
tasks.value = tasks.value.filter(task => !task.completed)
}
return {
tasks,
filter,
filteredTasks,
remainingCount,
addTask,
removeTask,
toggleTask,
updateTaskText,
setFilter,
clearCompleted
}
}
2. Local Storage Persistence Composable
// composables/useLocalStorage.js
import { watch } from 'vue'
export function useLocalStorage(key, state) {
// Load initial state from localStorage if it exists
try {
const storedValue = localStorage.getItem(key)
if (storedValue) {
state.value = JSON.parse(storedValue)
}
} catch (err) {
console.error(`Error reading ${key} from localStorage:`, err)
}
// Watch for changes and update localStorage
watch(
state,
(newValue) => {
try {
localStorage.setItem(key, JSON.stringify(newValue))
} catch (err) {
console.error(`Error saving ${key} to localStorage:`, err)
}
},
{ deep: true }
)
return state
}
3. Main App Component
<template>
<div class="todo-app">
<h1>Task Manager</h1>
<!-- Task Input -->
<div class="task-input">
<input
ref="inputRef"
v-model="newTask"
@keyup.enter="handleAddTask"
placeholder="What needs to be done?"
>
<button @click="handleAddTask">Add</button>
</div>
<!-- Filter Controls -->
<div class="task-filters">
<button
:class="{ active: filter === 'all' }"
@click="setFilter('all')"
>All</button>
<button
:class="{ active: filter === 'active' }"
@click="setFilter('active')"
>Active</button>
<button
:class="{ active: filter === 'completed' }"
@click="setFilter('completed')"
>Completed</button>
</div>
<!-- Task List -->
<ul class="task-list">
<li v-for="task in filteredTasks" :key="task.id" class="task-item">
<div v-if="editingId === task.id" class="task-edit">
<input
v-model="editText"
@blur="finishEditing"
@keyup.enter="finishEditing"
@keyup.escape="cancelEditing"
ref="editInputRef"
>
</div>
<div v-else class="task-view">
<input
type="checkbox"
:checked="task.completed"
@change="toggleTask(task.id)"
>
<span
:class="{ completed: task.completed }"
@dblclick="startEditing(task)"
>
{{ task.text }}
</span>
<button @click="removeTask(task.id)" class="delete-btn">×</button>
</div>
</li>
</ul>
<!-- Task Summary -->
<div class="task-summary" v-if="tasks.length > 0">
<span>{{ remainingCount }} items left</span>
<button @click="clearCompleted" v-if="tasks.length > remainingCount">
Clear completed
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useTasks } from './composables/useTasks'
import { useLocalStorage } from './composables/useLocalStorage'
// Task management logic
const {
tasks,
filter,
filteredTasks,
remainingCount,
addTask,
removeTask,
toggleTask,
updateTaskText,
setFilter,
clearCompleted
} = useTasks()
// Persist tasks to localStorage
useLocalStorage('tasks', tasks)
// Input refs
const inputRef = ref(null)
const editInputRef = ref(null)
// Task input state
const newTask = ref('')
// Editing state
const editingId = ref(null)
const editText = ref('')
// Focus the main input on mount
onMounted(() => {
inputRef.value.focus()
})
// Add task and clear input
function handleAddTask() {
if (newTask.value.trim()) {
addTask(newTask.value)
newTask.value = ''
inputRef.value.focus()
}
}
// Start editing a task
function startEditing(task) {
editingId.value = task.id
editText.value = task.text
// Focus the edit input after DOM update
nextTick(() => {
if (editInputRef.value) {
editInputRef.value.focus()
}
})
}
// Finish editing and save changes
function finishEditing() {
if (editingId.value !== null) {
updateTaskText(editingId.value, editText.value)
editingId.value = null
}
}
// Cancel editing without saving
function cancelEditing() {
editingId.value = null
}
</script>
<style scoped>
.todo-app {
max-width: 500px;
margin: 0 auto;
padding: 20px;
}
/* Other styles... */
</style>
This example demonstrates:
- Using composables for reusable, cohesive logic (task management, localStorage)
- Reactive state with
refand computed properties - Template refs for focusing inputs
- Event handling and form inputs
- Conditional rendering and list rendering
- Lifecycle hooks (
onMounted) - Using
nextTickfor DOM updates
Activities for Practice
Exercise 1: Convert Options API to Composition API
Take a component written with the Options API and convert it to use the Composition API with <script setup>.
Start with this component:
<template>
<div>
<h2>{{ title }}</h2>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">Increment</button>
<button @click="reset">Reset</button>
<input v-model="title">
</div>
</template>
<script>
export default {
props: {
initialCount: {
type: Number,
default: 0
}
},
data() {
return {
count: this.initialCount,
title: 'Counter Component'
}
},
computed: {
doubleCount() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
},
reset() {
this.count = this.initialCount
}
},
mounted() {
console.log('Component mounted')
},
watch: {
count(newValue, oldValue) {
console.log(`Count changed from ${oldValue} to ${newValue}`)
}
}
}
</script>
Exercise 2: Create a Composition API Form Validation
Create a form validation system using the Composition API:
-
Create a
useFormValidationcomposable that:- Accepts an object with form fields and validation rules
- Returns validation state, errors, and a validate function
- Supports rules like 'required', 'email', 'minLength', etc.
-
Build a registration form component that uses this composable with:
- Name (required)
- Email (required, email format)
- Password (required, min 8 characters)
- PasswordConfirm (must match password)
- Display validation errors in the UI
- Only enable the submit button when all validations pass
Exercise 3: Build a Composable for API Requests
Extend the useFetch composable to create a more comprehensive API request composable:
-
Create a
useApicomposable that supports:- Multiple HTTP methods (GET, POST, PUT, DELETE)
- Authentication headers
- Request/response interceptors
- Automatic error handling
- Request cancellation
-
Build a simple UI that demonstrates:
- Loading states during requests
- Displaying data when successful
- Showing error messages on failure
- Cancelling a request in progress
You can use free APIs like JSONPlaceholder or a mock API for testing.