COMPOSITION API ref reactive computed watch
Frontend

Vue.js 3 Composition API Guide

Mayur Dabhi
Mayur Dabhi
March 23, 2026
22 min read

Vue.js 3 introduced the Composition API as a powerful new way to organize and reuse component logic. While the Options API remains fully supported, the Composition API offers better TypeScript support, more flexible code organization, and improved logic extraction patterns. Whether you're building a new Vue 3 project or migrating from Vue 2, mastering the Composition API is essential for modern Vue development.

In this comprehensive guide, we'll explore every aspect of the Composition API—from basic reactivity primitives like ref and reactive to advanced patterns like composables. You'll learn through practical examples and diagrams that make even complex concepts crystal clear.

What You'll Learn
  • The fundamentals: ref, reactive, and reactivity in Vue 3
  • Computed properties and watchers in the Composition API
  • Lifecycle hooks: onMounted, onUpdated, onUnmounted, and more
  • Building reusable composables (custom hooks)
  • Comparing Options API vs Composition API
  • Best practices and real-world patterns
  • Migrating from Options API to Composition API

Why the Composition API?

Before diving into the code, let's understand why Vue 3 introduced the Composition API. The Options API, while intuitive for beginners, has limitations when components grow large or when you need to share logic between components.

Options API vs Composition API: Code Organization Options API data() { featureA: ... } data() { featureB: ... } computed: { featureAComputed } methods: { featureBMethod } watch: { featureAWatcher } ❌ Logic scattered by option type Composition API // Feature A const featureA = ref(...) const featureAComputed = computed(...) watch(featureA, ...) // Feature B const featureB = reactive(...) function featureBMethod() {...} ✅ Logic grouped by feature

The Composition API allows you to organize code by logical concern rather than option type

Better Code Organization

Group related logic together instead of splitting across data, methods, computed, and watch options.

Reusable Logic

Extract and share stateful logic between components using composables (similar to React hooks).

TypeScript Support

First-class TypeScript support with better type inference and less boilerplate.

Smaller Bundle Size

Better tree-shaking because imports are explicit and unused features can be eliminated.

Reactivity Fundamentals

The Composition API provides two primary ways to create reactive state: ref and reactive. Understanding when to use each is crucial for effective Vue 3 development.

ref() - Reactive References

The ref() function creates a reactive reference that wraps any value—primitives, objects, or arrays. Access the value using the .value property in JavaScript (but not in templates, where Vue auto-unwraps).

ref() - Basic Usage
import { ref } from 'vue'

export default {
  setup() {
    // Create reactive references
    const count = ref(0)
    const message = ref('Hello Vue 3!')
    const user = ref({ name: 'John', age: 25 })

    // Access and modify using .value
    function increment() {
      count.value++  // Must use .value in JavaScript
    }

    function updateUser() {
      user.value.name = 'Jane'  // Deep reactivity
    }

    // Return values for template
    return { count, message, user, increment, updateUser }
  }
}
Template - Auto-unwrapping
<template>
  <div>
    <!-- No .value needed in templates! -->
    <p>Count: {{ count }}</p>
    <p>{{ message }}</p>
    <p>User: {{ user.name }}, Age: {{ user.age }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

reactive() - Reactive Objects

The reactive() function creates a deeply reactive proxy of an object. Unlike ref, you don't need .value—you access properties directly. However, it only works with objects (not primitives).

reactive() - Usage
import { reactive } from 'vue'

export default {
  setup() {
    // Create a reactive object
    const state = reactive({
      count: 0,
      user: {
        name: 'John',
        email: 'john@example.com'
      },
      items: ['Apple', 'Banana', 'Orange']
    })

    // Direct property access - no .value needed!
    function increment() {
      state.count++
    }

    function addItem(item) {
      state.items.push(item)  // Array methods are reactive
    }

    function updateUser(name) {
      state.user.name = name  // Nested objects are reactive
    }

    return { state, increment, addItem, updateUser }
  }
}
Reactivity Caveat

Don't destructure reactive() objects—you'll lose reactivity! Use toRefs() if you need to destructure:

// ❌ BAD - loses reactivity
const { count } = state

// ✅ GOOD - maintains reactivity
const { count } = toRefs(state)

ref vs reactive: When to Use Which

Aspect ref() reactive()
Value Types Any (primitives, objects, arrays) Objects and arrays only
Access in JS Via .value Direct property access
Reassignment Can replace entire value Cannot replace entire object
Destructuring Safe to destructure Loses reactivity (use toRefs)
Best For Primitives, single values Complex objects, forms
Pro Tip

When in doubt, use ref(). It's more flexible and the .value syntax makes it clear when you're working with reactive state. Many Vue developers prefer using ref() exclusively for consistency.

Computed Properties

Computed properties in the Composition API work similarly to the Options API but are created using the computed() function. They're automatically cached and only re-evaluate when their reactive dependencies change.

computed() - Examples
import { ref, computed } from 'vue'

export default {
  setup() {
    const firstName = ref('John')
    const lastName = ref('Doe')
    const items = ref([
      { name: 'Apple', price: 1.5 },
      { name: 'Banana', price: 0.75 },
      { name: 'Orange', price: 2.0 }
    ])

    // Read-only computed (most common)
    const fullName = computed(() => {
      return `${firstName.value} ${lastName.value}`
    })

    // Computed with getter and setter
    const fullNameWritable = computed({
      get() {
        return `${firstName.value} ${lastName.value}`
      },
      set(newValue) {
        const [first, last] = newValue.split(' ')
        firstName.value = first
        lastName.value = last || ''
      }
    })

    // Computed for derived data
    const totalPrice = computed(() => {
      return items.value.reduce((sum, item) => sum + item.price, 0)
    })

    const expensiveItems = computed(() => {
      return items.value.filter(item => item.price > 1)
    })

    return { 
      firstName, lastName, fullName, fullNameWritable,
      items, totalPrice, expensiveItems 
    }
  }
}
Computed Property Caching firstName "John" lastName "Doe" computed: fullName "John Doe" 🔒 Cached Multiple Access {{ fullName }} ✓ {{ fullName }} ✓ {{ fullName }} ✓ Only computed once!

Computed properties cache their result and only re-compute when dependencies change

Watchers

The Composition API provides two ways to watch reactive state: watch() for watching specific sources and watchEffect() for automatic dependency tracking.

watch() - Explicit Watching

watch() - Various Patterns
import { ref, reactive, watch } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const searchQuery = ref('')
    const user = reactive({ name: 'John', age: 25 })

    // Watch a single ref
    watch(count, (newVal, oldVal) => {
      console.log(`Count changed: ${oldVal} → ${newVal}`)
    })

    // Watch with options
    watch(searchQuery, async (newQuery) => {
      if (newQuery.length > 2) {
        const results = await fetchSearchResults(newQuery)
        // Update results...
      }
    }, { 
      debounce: 300,  // Only in Vue 3.4+
      immediate: false // Don't run on mount
    })

    // Watch a reactive object property (use getter)
    watch(
      () => user.name,
      (newName) => {
        console.log(`User name changed to: ${newName}`)
      }
    )

    // Watch multiple sources
    watch(
      [count, () => user.age],
      ([newCount, newAge], [oldCount, oldAge]) => {
        console.log(`Count or age changed`)
      }
    )

    // Deep watch entire object
    watch(user, (newUser) => {
      console.log('User object changed', newUser)
    }, { deep: true })

    return { count, searchQuery, user }
  }
}

watchEffect() - Automatic Tracking

Unlike watch(), watchEffect() automatically tracks any reactive dependencies used inside its callback and runs immediately on mount.

watchEffect() - Auto-tracking
import { ref, watchEffect } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const message = ref('Hello')

    // Runs immediately and re-runs when count or message changes
    watchEffect(() => {
      console.log(`Count: ${count.value}, Message: ${message.value}`)
      // Vue automatically tracks count and message as dependencies
    })

    // With cleanup function (useful for subscriptions)
    watchEffect((onCleanup) => {
      const timer = setInterval(() => {
        console.log('Tick', count.value)
      }, 1000)

      // Cleanup runs before effect re-runs or on unmount
      onCleanup(() => {
        clearInterval(timer)
      })
    })

    // Stop a watcher manually
    const stop = watchEffect(() => { /* ... */ })
    // Later: stop() to stop watching

    return { count, message }
  }
}

Lifecycle Hooks

The Composition API provides lifecycle hooks as functions that you import and call inside setup(). Each Options API lifecycle hook has a corresponding Composition API function prefixed with on.

Vue 3 Component Lifecycle 🚀 Creation setup() onBeforeMount 📦 Mounting onMounted DOM available 🔄 Updates onBeforeUpdate onUpdated On reactive changes 💥 Unmounting onBeforeUnmount onUnmounted Cleanup here Options API → Composition API Mapping beforeMount → onBeforeMount mounted → onMounted unmounted → onUnmounted

Lifecycle hooks execute at different phases of a component's life

Lifecycle Hooks Example
import { 
  ref,
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted
} from 'vue'

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

    onBeforeMount(() => {
      console.log('Component is about to mount')
      // DOM not available yet
    })

    onMounted(() => {
      console.log('Component mounted!')
      // DOM is now available
      console.log(elementRef.value) // Access DOM element
      
      // Common: fetch data, set up subscriptions
      fetchData()
    })

    onBeforeUpdate(() => {
      console.log('Component is about to update')
    })

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

    onBeforeUnmount(() => {
      console.log('Component is about to unmount')
      // Start cleanup
    })

    onUnmounted(() => {
      console.log('Component unmounted!')
      // Clean up: remove event listeners, cancel subscriptions
    })

    return { count, elementRef }
  }
}

Composables: Reusable Logic

One of the most powerful features of the Composition API is the ability to extract and reuse stateful logic through composables. A composable is simply a function that uses Composition API features and returns reactive state and methods.

1

Create a Composable

Extract reusable logic into a function, conventionally named with a "use" prefix (e.g., useMouse, useFetch).

2

Return Reactive State

Return refs, reactive objects, computed properties, and functions that components can use.

3

Use in Any Component

Import and call the composable in any component's setup() to get independent instances of the state.

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() handles both refs and plain values
    const urlValue = toValue(url)
    
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch(urlValue)
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  })

  return { data, error, loading }
}
Using Composables in Components
<script setup>
import { useMouse } from './composables/useMouse'
import { useFetch } from './composables/useFetch'

// Use mouse position
const { x, y } = useMouse()

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

<template>
  <div>
    <p>Mouse: ({{ x }}, {{ y }})</p>
    
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error.message }}</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">{{ user.name }}</li>
    </ul>
  </div>
</template>

Script Setup Syntax

Vue 3.2 introduced <script setup>, a compile-time syntactic sugar that makes the Composition API even more concise. Variables and imports are automatically exposed to the template without needing to return them.

<script>
import { ref, computed } from 'vue'
import MyComponent from './MyComponent.vue'

export default {
  components: { MyComponent },
  props: {
    title: String
  },
  emits: ['update'],
  setup(props, { emit }) {
    const count = ref(0)
    const doubled = computed(() => count.value * 2)
    
    function increment() {
      count.value++
      emit('update', count.value)
    }

    return { count, doubled, increment }
  }
}
</script>
<script setup>
import { ref, computed } from 'vue'
import MyComponent from './MyComponent.vue'

// Props with defineProps
const props = defineProps({
  title: String
})

// Emits with defineEmits
const emit = defineEmits(['update'])

// Everything is automatically exposed!
const count = ref(0)
const doubled = computed(() => count.value * 2)

function increment() {
  count.value++
  emit('update', count.value)
}
</script>

<!-- MyComponent is auto-registered! -->
Why Use <script setup>?
  • Less boilerplate—no need to return values or register components
  • Better TypeScript type inference
  • Better runtime performance (compiled to render function in same scope)
  • Smaller bundle size

Migration from Options API

If you're migrating from the Options API, here's a quick reference for translating common patterns:

Options API Composition API
data() { return { count: 0 } } const count = ref(0)
computed: { doubled() { return this.count * 2 } } const doubled = computed(() => count.value * 2)
methods: { increment() { this.count++ } } function increment() { count.value++ }
watch: { count(newVal) { ... } } watch(count, (newVal) => { ... })
mounted() { ... } onMounted(() => { ... })
this.$refs.myElement const myElement = ref(null)

Best Practices

1. Organize by Feature, Not by Option

Group related reactive state, computed properties, and methods together. This makes it easier to understand and maintain your code.

// ✅ Good: Grouped by feature
// User feature
const user = ref(null)
const isLoggedIn = computed(() => !!user.value)
function login() { /* ... */ }
function logout() { /* ... */ }

// Cart feature
const cartItems = ref([])
const cartTotal = computed(() => /* ... */)
function addToCart(item) { /* ... */ }

2. Extract Logic into Composables

When logic becomes complex or needs to be reused, extract it into a composable function. Name it with the use prefix.

// composables/useAuth.js
export function useAuth() {
  const user = ref(null)
  const isLoggedIn = computed(() => !!user.value)
  
  async function login(credentials) { /* ... */ }
  function logout() { /* ... */ }
  
  return { user, isLoggedIn, login, logout }
}

3. Use ref() for Primitives, reactive() for Objects

While both work, using ref() for primitives and reactive() for complex objects is a common convention. Alternatively, use ref() for everything for consistency.

// Convention 1: Mixed
const count = ref(0)           // Primitive
const form = reactive({        // Object
  name: '',
  email: ''
})

// Convention 2: All refs (also valid)
const count = ref(0)
const form = ref({ name: '', email: '' })

4. Always Clean Up Side Effects

Use onUnmounted or the cleanup function in watchEffect to clean up subscriptions, timers, and event listeners.

onMounted(() => {
  window.addEventListener('resize', handleResize)
})

onUnmounted(() => {
  window.removeEventListener('resize', handleResize)
})

Conclusion

The Vue.js 3 Composition API represents a significant evolution in how we build Vue applications. By providing a more flexible way to organize component logic, better TypeScript support, and powerful composition patterns through composables, it addresses many limitations developers faced with the Options API in larger applications.

Key takeaways from this guide:

Remember, the Options API isn't deprecated—both APIs can coexist, and you can even mix them in the same component. Start by trying the Composition API in new components or when refactoring complex ones. As you get comfortable, you'll appreciate the improved organization and reusability it provides.

Vue.js Composition API JavaScript Frontend Vue 3
Mayur Dabhi

Mayur Dabhi

Full-stack developer passionate about building clean, efficient web applications. Experienced in Laravel, React, Vue.js, and modern development practices.