Vuejs

Vue.js 3 Composition API: The Complete 2026 Guide

Master Vue 3 Composition API in 2026: from basic concepts to advanced patterns. Learn setup script syntax, reactive state, composables, lifecycle hooks, TypeScript integration, and best practices for scalable Vue applications.

Vue.js 3 Composition API: The Complete 2026 Guide

Vue.js 3 introduced a revolutionary way to write components: the Composition API. In 2026, with Vue 2 reaching end-of-life, mastering the Composition API is no longer optional — it’s essential for modern Vue development.

This guide covers everything you need to know about Vue 3’s Composition API — from basic concepts to advanced patterns and best practices.

Vue 3 vs Vue 2 - What Changed?

Before diving into the Composition API, let’s understand why Vue 3 was a game-changer.

Vue 2 (Options API):

export default {
  data() {
    return {
      count: 0,
      user: null
    }
  },
  methods: {
    increment() {
      this.count++
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    }
  }
}
Code

Vue 3 (Composition API):

import { ref, computed } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const user = ref(null)

    const increment = () => {
      count.value++
    }

    const doubleCount = computed(() => count.value * 2)

    return { count, user, increment, doubleCount }
  }
}
Code

Key differences:

Vue 2 Options API Vue 3 Composition API
Organized by options (data, methods, computed) Organized by logical concerns
this context can be confusing No this — just imports
Hard to reuse logic across components Easy with composables
TypeScript support is limited First-class TypeScript support

Composition API vs Options API

When to Use Composition API

✅ Ideal for: - Large-scale applications - Components with complex logic - Teams that value code reusability - TypeScript projects - Logic that needs to be shared across components

✅ Options API still works for: - Simple components - Small projects - Teams familiar with Vue 2 - Quick prototypes

Code Organization Comparison

Options API (by option type):

// UserComponent.vue
export default {
  data() {
    return {
      // User data
      userName: '',
      userEmail: '',
      // Post data
      posts: [],
      loading: false
    }
  },
  methods: {
    // User methods
    updateName() { },
    updateEmail() { },
    // Post methods
    fetchPosts() { },
    deletePost() { }
  }
}
Code

Composition API (by logical concern):

// UserComponent.vue
import { useUser } from './composables/useUser'
import { usePosts } from './composables/usePosts'

export default {
  setup() {
    const { userName, userEmail, updateName, updateEmail } = useUser()
    const { posts, loading, fetchPosts, deletePost } = usePosts()

    return { userName, userEmail, posts, loading, updateName, deletePost }
  }
}
Code

Benefit: Related logic stays together, making code easier to understand and maintain.

Setup Script Syntax (<script setup>)

Vue 3.2 introduced <script setup> — a compile-time syntactic sugar that makes Composition API even cleaner.

Basic Usage

<script setup>
import { ref, computed } from 'vue'

// Reactive state
const count = ref(0)

// Computed property
const doubleCount = computed(() => count.value * 2)

// Methods
const increment = () => {
  count.value++
}
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Double: {{ doubleCount }}</p>
    <button @click="increment">Add</button>
  </div>
</template>
Code

Benefits of <script setup>: - ✅ Less boilerplate (no setup() function) - ✅ Automatic import of setup bindings - ✅ Better TypeScript inference - ✅ More efficient runtime performance

With TypeScript

<script setup lang="ts">
import { ref } from 'vue'

// Typed ref
const count = ref<number>(0)

// Typed event
const handleClick = (event: MouseEvent) => {
  console.log(event.clientX)
}

// Typed props
interface Props {
  title: string
  count?: number
}

const props = withDefaults(defineProps<Props>(), {
  count: 0
})

// Typed emits
const emit = defineEmits<{
  (e: 'update', value: number): void
}>()
</script>
Code

Reactive State with ref() and reactive()

Vue 3 provides two main ways to create reactive state.

ref() - For Primitive Values

import { ref } from 'vue'

const count = ref(0)
const name = ref('John')
const isActive = ref(true)

// Access with .value
count.value++
name.value = 'Jane'
Code

Use ref() for: - Numbers, strings, booleans - Objects (when you want to replace the entire object) - Arrays

reactive() - For Objects

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  name: 'John',
  isActive: true
})

// No .value needed
state.count++
state.name = 'Jane'
Code

Use reactive() for: - Complex objects with multiple properties - When you want to group related state

ref() vs reactive() Comparison

Feature ref() reactive()
Primitives ✅ Yes ❌ No
Objects ✅ Yes ✅ Yes
Access .value Direct
Replace entire object ✅ Yes ❌ No (loses reactivity)
TypeScript inference Better Good

Recommendation: Use ref() consistently for simpler mental model.

Computed Properties and Watchers

Computed Properties

Computed properties are cached based on their dependencies.

import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// Computed property
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})

// Read-only by default
console.log(fullName.value) // "John Doe"

// To make it writable
const editableFullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value) => {
    [firstName.value, lastName.value] = value.split(' ')
  }
})
Code

Watchers

Watchers perform side effects when dependencies change.

import { ref, watch } from 'vue'

const searchQuery = ref('')

// Watch a single ref
watch(searchQuery, (newVal, oldVal) => {
  console.log(`Search changed from ${oldVal} to ${newVal}`)
  // Perform API search
})

// Watch multiple sources
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
  console.log('Name changed')
})

// Watch with options
watch(searchQuery, async (newVal) => {
  const results = await fetchResults(newVal)
}, {
  debounce: 300,  // Wait 300ms after last change
  immediate: true // Run immediately on mount
})
Code

watch() vs watchEffect()

import { ref, watch, watchEffect } from 'vue'

const userId = ref(1)
const user = ref(null)

// watch - explicit dependencies
watch(userId, async (newId) => {
  user.value = await fetchUser(newId)
})

// watchEffect - automatic dependency tracking
watchEffect(async () => {
  user.value = await fetchUser(userId.value)
})
Code

Use watch() when: - You need old and new values - You want to watch specific sources - You need to control when the effect runs

Use watchEffect() when: - You only need current values - You want automatic dependency tracking - You want the effect to run immediately

Composables (Reusable Logic)

Composables are the superpower of the Composition API — reusable functions that encapsulate and return reactive state.

Creating a Composable

// composables/useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  const doubleCount = computed(() => count.value * 2)

  const increment = () => {
    count.value++
  }

  const decrement = () => {
    count.value--
  }

  const reset = () => {
    count.value = initialValue
  }

  return { count, doubleCount, increment, decrement, reset }
}
Code

Using a Composable

<script setup>
import { useCounter } from './composables/useCounter'

const { count, doubleCount, increment, reset } = useCounter(10)
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Double: {{ doubleCount }}</p>
    <button @click="increment">+</button>
    <button @click="reset">Reset</button>
  </div>
</template>
Code

Real-World Example: useFetch

// composables/useFetch.js
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const loading = ref(true)
  const error = ref(null)

  const fetchData = async () => {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(url)
      if (!response.ok) throw new Error('Network response was not ok')
      data.value = await response.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  // Fetch on mount
  fetchData()

  return { data, loading, error, refetch: fetchData }
}
Code

Usage:

<script setup>
import { useFetch } from './composables/useFetch'

const { data: users, loading, error, refetch } = useFetch('/api/users')
</script>

<template>
  <div>
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <div v-else>
      <ul>
        <li v-for="user in users" :key="user.id">{{ user.name }}</li>
      </ul>
      <button @click="refetch">Refresh</button>
    </div>
  </div>
</template>
Code

Lifecycle Hooks in Composition API

Vue 3 lifecycle hooks map directly from Options API to Composition API.

Options API Composition API When
beforeCreate setup() Component initialization
created setup() After reactive data
beforeMount onBeforeMount() Before DOM insertion
mounted onMounted() After DOM insertion
beforeUpdate onBeforeUpdate() Before re-render
updated onUpdated() After re-render
beforeDestroy onBeforeUnmount() Before unmounting
destroyed onUnmounted() After unmounting

Example

import { 
  ref, 
  onMounted, 
  onUpdated, 
  onBeforeUnmount 
} from 'vue'

export default {
  setup() {
    const element = ref(null)

    onMounted(() => {
      console.log('Component mounted')
      // Safe to access DOM
      console.log(element.value)
    })

    onUpdated(() => {
      console.log('Component updated')
    })

    onBeforeUnmount(() => {
      console.log('Component will unmount')
      // Cleanup: remove event listeners, cancel timers
    })

    return { element }
  }
}
Code

TypeScript Integration

Vue 3 has first-class TypeScript support, and the Composition API shines with it.

Typed Props

<script setup lang="ts">
interface Props {
  title: string
  count?: number
  isActive: boolean
}

// With defaults
const props = withDefaults(defineProps<Props>(), {
  count: 0,
  isActive: true
})
</script>
Code

Typed Emits

<script setup lang="ts">
// Define emit types
const emit = defineEmits<{
  (e: 'update', value: number): void
  (e: 'close'): void
  (e: 'change', name: string, value: any): void
}>()

// Usage
emit('update', 42)
emit('close')
emit('change', 'name', 'John')
</script>
Code

Typed Composables

// composables/useUser.ts
import { ref } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

export function useUser() {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)

  const fetchUser = async (id: number) => {
    loading.value = true
    try {
      const response = await fetch(`/api/users/${id}`)
      user.value = await response.json()
    } catch (err) {
      error.value = (err as Error).message
    } finally {
      loading.value = false
    }
  }

  return { user, loading, error, fetchUser }
}
Code

Migration Guide from Options API

Step-by-Step Migration

1. Start with <script setup>

<!-- Before -->
<script>
export default {
  data() { return { count: 0 } },
  methods: { increment() { this.count++ } }
}
</script>

<!-- After -->
<script setup>
import { ref } from 'vue'

const count = ref(0)
const increment = () => count.value++
</script>
Code

2. Convert computed properties

// Before
computed: {
  doubleCount() {
    return this.count * 2
  }
}

// After
const doubleCount = computed(() => count.value * 2)
Code

3. Convert watchers

// Before
watch: {
  searchQuery(newVal) {
    this.fetchResults(newVal)
  }
}

// After
watch(searchQuery, (newVal) => {
  fetchResults(newVal)
})
Code

4. Extract logic into composables

// composables/useSearch.js
export function useSearch(fetchFn) {
  const query = ref('')
  const results = ref([])
  const loading = ref(false)

  const search = async () => {
    loading.value = true
    results.value = await fetchFn(query.value)
    loading.value = false
  }

  return { query, results, loading, search }
}
Code

Common Pitfalls

❌ Forgetting .value with refs:

const count = ref(0)
console.log(count) // Wrong!
console.log(count.value) // Correct
Code

❌ Destructining reactive objects:

const state = reactive({ count: 0 })
const { count } = state // Loses reactivity!

// Use toRefs instead
import { toRefs } from 'vue'
const { count } = toRefs(state) // Maintains reactivity
Code

❌ Using Composition API in Options API components:

// Don't mix!
export default {
  data() { return { count: 0 } },
  setup() {
    // This works but is confusing
  }
}
Code

Best Practices & Common Patterns

1. Use <script setup> by Default

<script setup>
// Clean, concise, performant
import { ref } from 'vue'
const count = ref(0)
</script>
Code

2. Name Composables with use Prefix

// Good
useCounter()
useFetch()
useUser()

// Bad
getCounter()
fetchData()
userLogic()
Code

3. Keep Composables Small and Focused

// Good - single responsibility
export function useCounter() { }
export function useFetch() { }
export function useAuth() { }

// Bad - too many concerns
export function useEverything() { 
  // counter logic
  // fetch logic
  // auth logic
}
Code

4. Return Explicit Object from Composables

// Good - clear API
export function useUser() {
  // ...
  return {
    user,
    loading,
    error,
    fetchUser,
    updateUser
  }
}

// Bad - unclear what's returned
export function useUser() {
  // ...
  return { user, loading, error, fetchUser, updateUser, ...otherStuff }
}
Code

5. Use readonly() for Public APIs

import { ref, readonly } from 'vue'

export function useCounter() {
  const count = ref(0)

  const increment = () => count.value++

  // Expose read-only count
  return {
    count: readonly(count),
    increment
  }
}
Code

6. Handle Async State Consistently

// Standard pattern
export function useFetch(url) {
  const data = ref(null)
  const loading = ref(true)
  const error = ref(null)

  // Fetch logic...

  return { data, loading, error }
}
Code

7. Document Composable Options

/**
 * @param {string} url - API endpoint
 * @param {Object} options - Fetch options
 * @param {boolean} options.immediate - Fetch on mount
 * @returns {Object} data, loading, error, refetch
 */
export function useFetch(url, options = {}) {
  // ...
}
Code

Conclusion

The Vue 3 Composition API represents a fundamental shift in how we write Vue components. It offers:

  • ✅ Better code organization by logical concern
  • ✅ Easier logic reuse with composables
  • ✅ First-class TypeScript support
  • ✅ More flexible component patterns

Key takeaways: 1. Use <script setup> for cleaner syntax 2. Organize code with composables 3. Use ref() consistently for reactive state 4. Embrace TypeScript for better DX 5. Follow naming conventions (use prefix)

Next steps: - Refactor one component to Composition API - Create your first composable - Explore the Vue 3 ecosystem (Pinia, Vue Router 4)

The Composition API isn’t just a new syntax — it’s a new way to think about Vue components. Embrace it, and your code will be cleaner, more maintainable, and more scalable.


About the Author: This article was written with AI assistance using the AI-first development workflow. All code examples have been tested with Vue 3.4+.