· Michał Roman · Tutorials · 7 min read
Understanding Vue 3's Composition API - From options to composition - a practical guide
Learn Vue 3 Composition API - Transition from Options API, improve code organization, and boost reusability with composables
Introduction
Vue 3’s Composition API represents a fundamental shift in how we structure Vue applications. While the Options API served us well, larger applications often faced challenges with code organization and logic reuse. This guide will walk you through the transition from Options to Composition API, using a practical task management component as our running example.
Why Composition API?
Before diving into the code, let’s understand why Vue introduced the Composition API. The Options API organizes code by options: data
, methods
, computed
, etc. While intuitive for simple components, this approach has limitations:
Logic for a single feature gets split across multiple options
Reusing logic between components requires complex mixins
TypeScript support is limited due to this organization
The Composition API solves these issues by allowing us to organize code by logical concerns rather than option types. Let’s see how this works in practice.
Core concepts through practice
Let’s build our task management component step by step. We’ll start with a simple version in Options API and gradually refactor it to Composition API, adding features along the way.
Version 1: Basic task list
First, here’s our initial component using Options API:
<!-- TaskManager.vue (Options API) -->
<template>
<div class="task-manager">
<input v-model="newTask" @keyup.enter="addTask" placeholder="Add new task">
<ul>
<li v-for="task in tasks" :key="task.id">
<input type="checkbox" v-model="task.completed">
{{ task.title }}
</li>
</ul>
<div>Completed: {{ completedCount }} / {{ tasks.length }}</div>
</div>
</template>
<script>
export default {
data() {
return {
newTask: '',
tasks: []
}
},
computed: {
completedCount() {
return this.tasks.filter(task => task.completed).length
}
},
methods: {
addTask() {
if (!this.newTask.trim()) return
this.tasks.push({
id: Date.now(),
title: this.newTask,
completed: false
})
this.newTask = ''
}
}
}
</script>
Now, let’s refactor this to Composition API:
<!-- TaskManager.vue (Composition API) -->
<template>
<div class="task-manager">
<input v-model="newTask" @keyup.enter="addTask" placeholder="Add new task">
<ul>
<li v-for="task in tasks" :key="task.id">
<input type="checkbox" v-model="task.completed">
{{ task.title }}
</li>
</ul>
<div>Completed: {{ completedCount }} / {{ tasks.length }}</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
// State
const newTask = ref('')
const tasks = ref([])
// Computed
const completedCount = computed(() => {
return tasks.value.filter(task => task.completed).length
})
// Actions
const addTask = () => {
if (!newTask.value.trim()) return
tasks.value.push({
id: Date.now(),
title: newTask.value,
completed: false
})
newTask.value = ''
}
</script>
Let’s break down the key differences:
The
setup
scriptWe use
<script setup>
, a compile-time syntactic sugar that makes our code more conciseAll top-level variables and functions are automatically exposed to the template
No need for a return statement
Reactive state
Instead of
data()
, we useref()
to create reactive variablesWe access the value using
.value
in the script (but not in the template)Vue automatically unwraps refs in templates
Computed properties
Created using the
computed()
functionMore explicit dependency tracking
Clearer relationship between dependencies and computed values
Adding more features: Task categories
Let’s enhance our component with categories and filtering. This is where the Composition API’s benefits become more apparent:
<!-- TaskManager.vue (Enhanced Composition API) -->
<template>
<div class="task-manager">
<div class="filters">
<select v-model="currentCategory">
<option value="">All Categories</option>
<option v-for="category in categories"
:key="category"
:value="category">
{{ category }}
</option>
</select>
<button @click="showCompleted = !showCompleted">
{{ showCompleted ? 'Hide' : 'Show' }} Completed
</button>
</div>
<div class="add-task">
<input v-model="newTask" placeholder="Add new task">
<select v-model="newTaskCategory">
<option v-for="category in categories"
:key="category"
:value="category">
{{ category }}
</option>
</select>
<button @click="addTask">Add Task</button>
</div>
<ul>
<li v-for="task in filteredTasks" :key="task.id">
<input type="checkbox" v-model="task.completed">
{{ task.title }}
<span class="category-tag">{{ task.category }}</span>
</li>
</ul>
<div class="stats">
<div>Completed: {{ completedCount }} / {{ tasks.length }}</div>
<div>Tasks per category:</div>
<ul>
<li v-for="stat in categoryStats" :key="stat.category">
{{ stat.category }}: {{ stat.count }}
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
// State
const newTask = ref('')
const newTaskCategory = ref('work')
const currentCategory = ref('')
const showCompleted = ref(true)
const tasks = ref([])
const categories = ['work', 'personal', 'shopping']
// Computed properties
const filteredTasks = computed(() => {
return tasks.value.filter(task => {
const categoryMatch = !currentCategory.value || task.category === currentCategory.value
const completedMatch = showCompleted.value || !task.completed
return categoryMatch && completedMatch
})
})
const completedCount = computed(() =>
tasks.value.filter(task => task.completed).length
)
const categoryStats = computed(() => {
return categories.map(category => ({
category,
count: tasks.value.filter(task => task.category === category).length
}))
})
// Actions
const addTask = () => {
if (!newTask.value.trim()) return
tasks.value.push({
id: Date.now(),
title: newTask.value,
category: newTaskCategory.value,
completed: false
})
newTask.value = ''
}
</script>
Extracting reusable logic with composables
One of the biggest advantages of the Composition API is the ability to extract and reuse logic. Let’s create a composable for task management:
// composables/useTasks.js
import { ref, computed } from 'vue'
export function useTasks(categories) {
// State
const tasks = ref([])
const currentCategory = ref('')
const showCompleted = ref(true)
// Computed
const filteredTasks = computed(() => {
return tasks.value.filter(task => {
const categoryMatch = !currentCategory.value ||
task.category === currentCategory.value
const completedMatch = showCompleted.value || !task.completed
return categoryMatch && completedMatch
})
})
const completedCount = computed(() =>
tasks.value.filter(task => task.completed).length
)
const categoryStats = computed(() => {
return categories.map(category => ({
category,
count: tasks.value.filter(task => task.category === category).length
}))
})
// Actions
const addTask = (title, category) => {
if (!title.trim()) return
tasks.value.push({
id: Date.now(),
title,
category,
completed: false
})
}
const toggleTaskCompletion = (taskId) => {
const task = tasks.value.find(t => t.id === taskId)
if (task) {
task.completed = !task.completed
}
}
const toggleShowCompleted = () => {
showCompleted.value = !showCompleted.value
}
return {
// State
tasks,
currentCategory,
showCompleted,
// Computed
filteredTasks,
completedCount,
categoryStats,
// Actions
addTask,
toggleTaskCompletion,
toggleShowCompleted
}
}
Now we can simplify our component by using this composable:
<!-- TaskManager.vue -->
<script setup>
import { ref } from 'vue'
import { useTasks } from './composables/useTasks'
const categories = ['work', 'personal', 'shopping']
const newTask = ref('')
const newTaskCategory = ref('work')
const {
tasks,
currentCategory,
showCompleted,
filteredTasks,
completedCount,
categoryStats,
addTask: addTaskToList,
toggleShowCompleted
} = useTasks(categories)
const addTask = () => {
addTaskToList(newTask.value, newTaskCategory.value)
newTask.value = ''
}
</script>
Understanding Vue’s reactivity system
To truly master the Composition API, it’s important to understand how Vue’s reactivity system works under the hood:
Proxy-based reactivity
Vue 3 uses JavaScript Proxies to track property access and modifications
When you create a ref, Vue wraps the value in an object with a getter/setter
The
.value
property is a proxy that triggers dependency tracking
Dependency tracking
During render or computed property evaluation, Vue tracks which reactive properties are accessed
When a property changes, Vue knows exactly which computations need to be re-run
This is why we need
.value
in the script - it’s the proxy trigger point
Here’s a simplified visualization of how it works:
// Simplified internal representation of ref
function ref(value) {
const refObject = {
_value: value,
get value() {
track(refObject, 'value') // Tell Vue to track this dependency
return refObject._value
},
set value(newValue) {
refObject._value = newValue
trigger(refObject, 'value') // Tell Vue to trigger updates
}
}
return refObject
}
Migration strategy
When migrating from Options API to Composition API, follow these guidelines:
Start small
Begin with new components or simple existing ones
Focus on components that would benefit from better logic organization
Incremental migration
Vue 3 supports both APIs, so you can migrate gradually
Start by identifying logical features that can be extracted into composables
Replace options one at a time: data → refs, computed → computed(), etc.
Common patterns
Move related state and methods into composables
Use
provide/inject
for deep component communicationLeverage lifecycle hooks with
on
prefix (e.g.,onMounted
)
Best practices and tips
Naming conventions
Prefix composables with “use” (e.g.,
useTasks
)Keep ref names simple and descriptive
Group related functionality in single composables
Code organization
// Recommended structure // State declarations const x = ref() const y = ref() // Computed properties const z = computed() // Methods const doSomething = () => {} // Lifecycle hooks onMounted(() => {})
TypeScript integration
Use
defineProps
anddefineEmits
for prop and event typingLeverage Vue’s built-in type inference in
<script setup>
Define interfaces for complex state structures
Performance considerations
Use
shallowRef
for large objects that don’t need deep reactivityAvoid unnecessary computed properties
Remember that refs have minimal overhead in Vue 3
Conclusion
The Composition API represents a more flexible and maintainable way to build Vue applications. While the learning curve might seem steep at first, the benefits become clear as your applications grow in complexity. The ability to organize code by logical concerns, extract reusable functionality, and maintain better TypeScript integration makes it a powerful tool in the Vue ecosystem.
Remember that there’s no need to rewrite all your existing Options API code immediately. Vue 3’s support for both APIs means you can migrate at your own pace, focusing on components that would benefit most from the refactoring.
As you continue working with the Composition API, you’ll discover more patterns and best practices that suit your specific needs. The key is to start small, understand the fundamentals, and gradually build up to more complex use cases.