· 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:

  1. Logic for a single feature gets split across multiple options

  2. Reusing logic between components requires complex mixins

  3. 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:

  1. The setup script

    • We use <script setup>, a compile-time syntactic sugar that makes our code more concise

    • All top-level variables and functions are automatically exposed to the template

    • No need for a return statement

  2. Reactive state

    • Instead of data(), we use ref() to create reactive variables

    • We access the value using .value in the script (but not in the template)

    • Vue automatically unwraps refs in templates

  3. Computed properties

    • Created using the computed() function

    • More 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:

  1. 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

  2. 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:

  1. Start small

    • Begin with new components or simple existing ones

    • Focus on components that would benefit from better logic organization

  2. 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.

  3. Common patterns

    • Move related state and methods into composables

    • Use provide/inject for deep component communication

    • Leverage lifecycle hooks with on prefix (e.g., onMounted)

Best practices and tips

  1. Naming conventions

    • Prefix composables with “use” (e.g., useTasks)

    • Keep ref names simple and descriptive

    • Group related functionality in single composables

  2. Code organization

    // Recommended structure
    // State declarations
    const x = ref()
    const y = ref()
    
    // Computed properties
    const z = computed()
    
    // Methods
    const doSomething = () => {}
    
    // Lifecycle hooks
    onMounted(() => {})
    
  3. TypeScript integration

    • Use defineProps and defineEmits for prop and event typing

    • Leverage Vue’s built-in type inference in <script setup>

    • Define interfaces for complex state structures

  4. Performance considerations

    • Use shallowRef for large objects that don’t need deep reactivity

    • Avoid 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.

Back to Blog

Related Posts

View All Posts »

Advanced Tailwind tips and tricks

Unlock Tailwind CSS's potential with advanced tips, dynamic sizing, responsive layouts, and more to enhance your web development workflow