COMPOSITION API ref reactive computed watch
Frontend

Vue.js 3 Composition API Guide

Mayur Dabhi
Mayur Dabhi
March 23, 2026
22 min read

You open a Vue 2 component that's grown to 600 lines. The search feature's state is in data(), its filtering is in computed, the API call is in methods, and the debounce lives in watch. To change one feature you scroll through four sections, and when another component needs the same logic, your options are a mixin with name collisions or copy and paste.

The Composition API in Vue 3 fixes that by letting you group code by feature and pull it out into plain functions. The Options API is still fully supported, so this isn't a forced migration. Below I go through ref and reactive, computed values, watchers, lifecycle hooks, composables, <script setup>, and how to translate Options API code, plus the gotchas that tend to bite people the first week.

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?

The Options API is easy to pick up, and for small components it's fine. The trouble starts when a component handles several concerns at once, or when two components need the same stateful logic. The diagram shows the difference in how code ends up laid out.

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

Plain variables and functions infer types well, so you write fewer manual annotations than with this-based options.

Smaller Bundle Size

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

Reactivity Fundamentals

You get two ways to create reactive state: ref and reactive. They overlap a lot, and most confusion with the Composition API comes from mixing up their rules.

ref() - Reactive References

Use ref() to wrap any value: primitives, objects, or arrays. In JavaScript you read and write it through .value. In templates you don't, because Vue unwraps top-level refs for you.

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

reactive() returns a deeply reactive proxy of an object. There's no .value; you access properties directly. The catch is that it only works with objects, arrays, and collections like Map, 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

Destructuring a reactive() object gives you plain values, so your template stops updating and nothing warns you. The same happens if you reassign the whole thing (state = reactive({...})): the template still points at the old proxy. Use toRefs() when 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
Tip

When in doubt, use ref(). It handles every value type, and the .value makes it obvious in your script which variables are reactive. The Vue docs themselves recommend ref() as the default, and plenty of teams use nothing else.

Computed Properties

These behave the same as in the Options API, you just create them with computed(). The result is cached and only recalculated when one of its reactive dependencies changes. Keep the getter free of side effects: no API calls, no mutating other state. That's what watchers are for.

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

There are two options here: watch() when you want to name exactly what to watch, and watchEffect() when you want Vue to track whatever the callback reads.

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,  // Not a built-in option; Vue ignores it, so debounce yourself
      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 }
  }
}
Two Things That Trip People Up
  • Writing watch(user.name, ...) passes a plain string, not something reactive. Vue logs [Vue warn]: Invalid watch source and the callback never fires. Wrap it in a getter: () => user.name.
  • watch() has no debounce option. For search inputs, wrap the callback with a debounce helper (lodash's, or watchDebounced from VueUse), otherwise every keystroke fires a request.

watchEffect() - Automatic Tracking

watchEffect() runs right away and tracks every reactive value its callback reads, then re-runs when any of them change. Handy, but be careful with async callbacks: only values read before the first await get tracked.

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

Lifecycle hooks become functions you import and call inside setup(). Each Options API hook has a matching function with an on prefix. They have to be registered synchronously during setup; if you call onMounted after an await or inside a setTimeout, Vue warns that there's no active component instance and the hook won't run.

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

This is the part that replaces mixins. You move stateful logic into a plain function (a composable) that uses Composition API features and returns reactive state and methods. Unlike mixins, you can see exactly where each value comes from, and you can rename on destructure if two composables return the same name.

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

Since Vue 3.2 you can write <script setup> instead of a setup() function. It's compile-time sugar: top-level variables and imports are available in the template without a return statement, and imported components don't need registering.

<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 returning values or registering components
  • Better TypeScript type inference
  • Better runtime performance (compiled to render function in same scope)
  • Smaller bundle size

Migration from Options API

Here's how the common Options API patterns translate. The main habit to break is reaching for this, which doesn't exist inside setup().

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

Keep each feature's state, computed values, and functions next to each other, so changing one feature means reading one block of 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 a feature gets big or a second component needs it, move it into a composable with a use prefix. I wouldn't extract logic that's only used once and fits in 20 lines; that just adds a file to jump to.

// 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

Both work. One common convention is ref() for primitives and reactive() for objects like forms. The other is ref() for everything. Pick one per project and stick to it.

// 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)
})

When I'd Use the Composition API (and When I Wouldn't)

For a new Vue 3 project, especially one using TypeScript, I'd default to <script setup>. The type inference is better, composables beat mixins for sharing logic, and most of the ecosystem (Pinia, VueUse, Nuxt 3) writes its examples this way.

It isn't automatically the right call, though:

The bugs I'd watch for most after switching: losing reactivity by destructuring reactive() (or props, before Vue 3.5), forgetting .value in script code (a check like if (isLoading) is always true because the ref object is truthy), and registering lifecycle hooks after an await.

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.