Vue 3 Composition API: Complete Guide to Mastering Reactivity

Master Vue 3 Composition API with this practical guide. Learn ref, reactive, computed, watch, and composables to build performant Vue applications.

Vue 3 Composition API illustration with interconnected reactive code blocks

The Vue 3 Composition API represents a major evolution in how Vue components are structured. This approach organizes code by feature rather than by option, making it easier to reuse logic and maintain complex applications.

Prerequisites

This guide assumes basic knowledge of Vue.js. Examples use the <script setup> syntax introduced in Vue 3.2, which is now the recommended approach.

Understanding Reactivity with ref and reactive

Reactivity lies at the heart of Vue 3. Two main primitives create reactive data: ref for primitive values and reactive for complex objects.

The ref function creates a reactive reference that wraps a value. To access or modify this value in the script, the .value property must be used. In the template, Vue automatically unwraps this property.

Counter.vuejavascript
<script setup>
import { ref } from 'vue'

// Create a ref with initial value 0
const count = ref(0)

// Function that increments the counter
// Note: .value is required in the script
const increment = () => {
  count.value++
}

// Reset function
const reset = () => {
  count.value = 0
}
</script>

<template>
  <!-- In the template, no .value needed -->
  <div class="counter">
    <p>Counter: {{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="reset">Reset</button>
  </div>
</template>

For objects with multiple related properties, reactive offers a more natural syntax without needing .value.

UserProfile.vuejavascript
<script setup>
import { reactive } from 'vue'

// reactive for complex objects
// All properties are automatically reactive
const user = reactive({
  name: 'Jane Smith',
  email: 'jane@example.com',
  preferences: {
    theme: 'dark',
    notifications: true
  }
})

// Direct property modification (no .value)
const updateTheme = (newTheme) => {
  user.preferences.theme = newTheme
}

// Warning: do not reassign the entire object
// user = { ... } would break reactivity
</script>

<template>
  <div>
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <p>Theme: {{ user.preferences.theme }}</p>
  </div>
</template>

The general rule: use ref for primitive values (string, number, boolean) and reactive for structured objects.

Computed Properties with computed

Computed properties derive values from reactive state. They are cached and only recalculate when their dependencies change, making them highly performant.

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

// Product list
const products = ref([
  { id: 1, name: 'Laptop', price: 999, inStock: true },
  { id: 2, name: 'Mouse', price: 29, inStock: true },
  { id: 3, name: 'Keyboard', price: 79, inStock: false },
  { id: 4, name: 'Monitor', price: 299, inStock: true }
])

// Active filter
const showOnlyInStock = ref(false)

// computed: automatic filtering based on toggle
// Only recalculates if products or showOnlyInStock changes
const filteredProducts = computed(() => {
  if (showOnlyInStock.value) {
    return products.value.filter(p => p.inStock)
  }
  return products.value
})

// computed: total calculation with caching
const totalValue = computed(() => {
  return filteredProducts.value.reduce((sum, p) => sum + p.price, 0)
})

// computed: number of displayed items
const productCount = computed(() => filteredProducts.value.length)
</script>

<template>
  <div>
    <label>
      <input type="checkbox" v-model="showOnlyInStock" />
      Show only in-stock products
    </label>

    <p>{{ productCount }} products - Total: ${{ totalValue }}</p>

    <ul>
      <li v-for="product in filteredProducts" :key="product.id">
        {{ product.name }} - ${{ product.price }}
      </li>
    </ul>
  </div>
</template>

Computed properties can also be writable with a getter and setter, useful for bidirectional transformations.

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

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

// computed with getter AND setter
const fullName = computed({
  // Read: combine first and last name
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  // Write: split the string into first/last name
  set(newValue) {
    const parts = newValue.split(' ')
    firstName.value = parts[0] || ''
    lastName.value = parts.slice(1).join(' ') || ''
  }
})
</script>

<template>
  <!-- Modifying fullName updates firstName and lastName -->
  <input v-model="fullName" placeholder="Full name" />
  <p>First name: {{ firstName }}</p>
  <p>Last name: {{ lastName }}</p>
</template>

Watchers with watch and watchEffect

Watchers execute side effects in response to data changes. Vue 3 offers two approaches: watch for precise control and watchEffect for automatic tracking.

When to use watch vs watchEffect?

watch offers precise control over dependencies and provides both old and new values. watchEffect is simpler when all reactive dependencies used should trigger the effect.

SearchComponent.vuejavascript
<script setup>
import { ref, watch, watchEffect } from 'vue'

const searchQuery = ref('')
const searchResults = ref([])
const isLoading = ref(false)

// watch: observes a specific source
// Provides oldValue and newValue, allows debouncing
watch(searchQuery, async (newQuery, oldQuery) => {
  console.log(`Search changed from "${oldQuery}" to "${newQuery}"`)

  // Don't search if less than 3 characters
  if (newQuery.length < 3) {
    searchResults.value = []
    return
  }

  isLoading.value = true

  try {
    // Simulated API call
    const response = await fetch(`/api/search?q=${newQuery}`)
    searchResults.value = await response.json()
  } finally {
    isLoading.value = false
  }
}, {
  // Watcher options
  debounce: 300, // Wait 300ms after last keystroke
  immediate: false // Don't execute immediately
})

// watchEffect: automatic dependency tracking
// Runs immediately and on every change
watchEffect(() => {
  // All refs used here are automatically tracked
  console.log(`Current state: ${searchResults.value.length} results`)
  console.log(`Loading: ${isLoading.value}`)
})
</script>

<template>
  <div>
    <input v-model="searchQuery" placeholder="Search..." />
    <p v-if="isLoading">Loading...</p>
    <ul v-else>
      <li v-for="result in searchResults" :key="result.id">
        {{ result.title }}
      </li>
    </ul>
  </div>
</template>

To observe nested objects or arrays, the deep option is necessary with watch.

DeepWatch.vuejavascript
<script setup>
import { reactive, watch } from 'vue'

const settings = reactive({
  display: {
    theme: 'light',
    fontSize: 14
  },
  notifications: {
    email: true,
    push: false
  }
})

// deep: true to observe nested changes
watch(
  () => settings.display,
  (newDisplay) => {
    console.log('Display settings changed:', newDisplay)
    // Save to localStorage for example
    localStorage.setItem('display', JSON.stringify(newDisplay))
  },
  { deep: true }
)
</script>

Ready to ace your Vue.js / Nuxt.js interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Creating Reusable Composables

Composables are functions that encapsulate reusable reactive logic. This approach is one of the major strengths of the Composition API for sharing code between components.

composables/useFetch.jsjavascript
import { ref, watchEffect, toValue } from 'vue'

// Composable for HTTP requests
// Automatically handles loading, errors, and refetching
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const isLoading = ref(false)

  // Reusable fetch function
  async function fetchData() {
    isLoading.value = true
    error.value = null

    try {
      // toValue() allows accepting a ref or a value
      const response = await fetch(toValue(url))

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      data.value = await response.json()
    } catch (e) {
      error.value = e.message
    } finally {
      isLoading.value = false
    }
  }

  // watchEffect for automatic refetch if url is a ref
  watchEffect(() => {
    fetchData()
  })

  // Expose state and methods
  return {
    data,
    error,
    isLoading,
    refetch: fetchData
  }
}

This composable can then be used declaratively in any component.

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

// Simple composable usage
const { data: users, isLoading, error, refetch } = useFetch('/api/users')
</script>

<template>
  <div>
    <button @click="refetch" :disabled="isLoading">
      Refresh
    </button>

    <p v-if="isLoading">Loading users...</p>
    <p v-else-if="error" class="error">Error: {{ error }}</p>

    <ul v-else-if="users">
      <li v-for="user in users" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
  </div>
</template>

Here is another example of a composable for managing form state with validation.

composables/useForm.jsjavascript
import { reactive, computed } from 'vue'

// Form management composable
export function useForm(initialValues, validationRules) {
  // Form state
  const form = reactive({
    values: { ...initialValues },
    errors: {},
    touched: {}
  })

  // Validate a specific field
  const validateField = (field) => {
    const rules = validationRules[field]
    if (!rules) return true

    for (const rule of rules) {
      const result = rule(form.values[field])
      if (result !== true) {
        form.errors[field] = result
        return false
      }
    }

    form.errors[field] = null
    return true
  }

  // Validate entire form
  const validate = () => {
    let isValid = true
    for (const field in validationRules) {
      if (!validateField(field)) {
        isValid = false
      }
    }
    return isValid
  }

  // computed: form is valid if no errors
  const isValid = computed(() => {
    return Object.values(form.errors).every(e => !e)
  })

  // Mark a field as touched (to display errors)
  const touch = (field) => {
    form.touched[field] = true
    validateField(field)
  }

  // Reset the form
  const reset = () => {
    form.values = { ...initialValues }
    form.errors = {}
    form.touched = {}
  }

  return {
    form,
    isValid,
    validate,
    validateField,
    touch,
    reset
  }
}

Lifecycle Hook Management

The Composition API provides lifecycle hooks as functions to call within setup. These hooks execute code at specific moments in the component's lifecycle.

Important Cleanup

Always clean up side effects (timers, event listeners, subscriptions) in onUnmounted to avoid memory leaks.

LifecycleDemo.vuejavascript
<script setup>
import {
  ref,
  onMounted,
  onUnmounted,
  onBeforeUpdate,
  onUpdated
} from 'vue'

const windowWidth = ref(window.innerWidth)
const updateCount = ref(0)

// Handler for resize
const handleResize = () => {
  windowWidth.value = window.innerWidth
}

// onMounted: component inserted into DOM
// Ideal for initial API calls and event listeners
onMounted(() => {
  console.log('Component mounted')
  window.addEventListener('resize', handleResize)

  // Example: initialize a third-party library
  // chart = new Chart(chartRef.value, config)
})

// onUnmounted: component removed from DOM
// CRITICAL: clean up all side effects
onUnmounted(() => {
  console.log('Component unmounted')
  window.removeEventListener('resize', handleResize)

  // Clean up subscriptions, timers, etc.
  // chart?.destroy()
})

// onBeforeUpdate: before DOM update
onBeforeUpdate(() => {
  console.log('Before update')
})

// onUpdated: after DOM update
onUpdated(() => {
  updateCount.value++
  console.log(`DOM updated (${updateCount.value} times)`)
})
</script>

<template>
  <div>
    <p>Window width: {{ windowWidth }}px</p>
    <p>DOM updates: {{ updateCount }}</p>
  </div>
</template>

Template Refs and DOM Access

Template refs provide direct access to DOM elements or child component instances. This remains useful for cases where direct manipulation is necessary.

InputFocus.vuejavascript
<script setup>
import { ref, onMounted } from 'vue'

// ref for the DOM element
// Same name as the ref attribute in the template
const inputRef = ref(null)

// Function to focus the input
const focusInput = () => {
  // Access to the native DOM element
  inputRef.value?.focus()
}

// Auto-focus on mount
onMounted(() => {
  focusInput()
})

// Expose the method to parent if needed
defineExpose({
  focus: focusInput
})
</script>

<template>
  <div>
    <!-- The ref attribute binds the element to inputRef -->
    <input ref="inputRef" type="text" placeholder="Auto-focused" />
    <button @click="focusInput">Refocus</button>
  </div>
</template>

Parent-Child Communication with Props and Emits

The Composition API modernizes props and events declaration with defineProps and defineEmits, offering better TypeScript integration.

ChildComponent.vuejavascript
<script setup>
// defineProps: declares expected props
// With default values via withDefaults
const props = withDefaults(defineProps<{
  title: string
  count?: number
  items?: string[]
}>(), {
  count: 0,
  items: () => []
})

// defineEmits: declares emitted events
// Precise payload typing
const emit = defineEmits<{
  (e: 'update', value: number): void
  (e: 'submit', data: { title: string; items: string[] }): void
}>()

// Function that emits an event
const handleSubmit = () => {
  emit('submit', {
    title: props.title,
    items: props.items
  })
}

const increment = () => {
  emit('update', props.count + 1)
}
</script>

<template>
  <div class="card">
    <h3>{{ title }}</h3>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
    <button @click="handleSubmit">Submit</button>
  </div>
</template>
ParentComponent.vuejavascript
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const currentCount = ref(0)
const items = ref(['Item 1', 'Item 2'])

// Handler for update event
const onUpdate = (newValue) => {
  currentCount.value = newValue
}

// Handler for submit event
const onSubmit = (data) => {
  console.log('Data submitted:', data)
}
</script>

<template>
  <ChildComponent
    title="My Component"
    :count="currentCount"
    :items="items"
    @update="onUpdate"
    @submit="onSubmit"
  />
</template>

Ready to ace your Vue.js / Nuxt.js interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Provide and Inject for Dependency Injection

To share data between distant components without prop drilling, Vue 3 offers provide and inject.

App.vue (or an ancestor component)javascript
<script setup>
import { provide, ref, readonly } from 'vue'

// Global theme state
const theme = ref('light')

// Function to toggle theme
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

// provide: makes available to descendants
// readonly prevents direct modification by children
provide('theme', readonly(theme))
provide('toggleTheme', toggleTheme)
</script>

<template>
  <div :class="theme">
    <slot />
  </div>
</template>
DeepNestedComponent.vue (anywhere in the tree)javascript
<script setup>
import { inject } from 'vue'

// inject: retrieves value provided by an ancestor
// Default value if not found
const theme = inject('theme', 'light')
const toggleTheme = inject('toggleTheme', () => {})
</script>

<template>
  <div>
    <p>Current theme: {{ theme }}</p>
    <button @click="toggleTheme">
      Switch to {{ theme === 'light' ? 'dark' : 'light' }}
    </button>
  </div>
</template>

Conclusion

The Vue 3 Composition API offers a powerful and flexible approach to organizing Vue application code. Key concepts to remember:

  • ref for primitive values, reactive for complex objects
  • computed for derived values with automatic caching
  • watch and watchEffect for reactive side effects
  • Composables to extract and reuse logic between components
  • Functional lifecycle hooks (onMounted, onUnmounted, etc.)
  • provide/inject for dependency injection without prop drilling

This approach facilitates creating maintainable and testable applications while offering excellent TypeScript integration. The next step involves exploring advanced patterns like async composables and integration with Pinia for global state management.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#vue 3
#composition api
#javascript
#frontend
#reactivity

Share

Related articles