All posts
Vue 3 Composition API code with reactive state and composables
  • Vue
  • TypeScript
  • Frontend
  • JavaScript
  • Composition API

Vue 3 Composition API — A Practical Guide

A hands-on guide to the Vue 3 Composition API — reactive state with ref and reactive, computed properties, watchers, lifecycle hooks, composables, and TypeScript integration.

1 min read

The Composition API is Vue 3’s answer to the limitations of the Options API: logic can now be co-located by feature rather than scattered across data, methods, computed, and watch. This guide covers everything you need to write Vue 3 components with <script setup> and TypeScript.

Why the Composition API?

With the Options API, a single component handling multiple concerns quickly becomes hard to read — all the state for feature A is in data, the methods in methods, and the watchers in watch, spread across the file. The Composition API lets you group related logic together, and — more importantly — extract it into reusable composable functions.

<!-- Options API — logic scattered by option type -->
<script>
export default {
  data() {
    return { count: 0, user: null, loading: false }
  },
  methods: {
    increment() { this.count++ },
    async fetchUser() { /* ... */ }
  },
  computed: {
    doubled() { return this.count * 2 }
  }
}
</script>
<!-- Composition API — logic grouped by feature -->
<script setup lang="ts">
import { ref, computed } from 'vue'

// Counter feature
const count = ref(0)
const doubled = computed(() => count.value * 2)
const increment = () => count.value++

// User feature
const user = ref(null)
const loading = ref(false)
async function fetchUser() { /* ... */ }
</script>

ref and reactive

ref — for primitives and single values

ref wraps any value in a reactive container. In JavaScript/TypeScript code, access the value via .value. In templates, .value is unwrapped automatically.

import { ref } from 'vue'

const count = ref(0)           // Ref<number>
const name = ref('Alice')      // Ref<string>
const items = ref<string[]>([]) // Ref<string[]>

// In script: access via .value
count.value++
name.value = 'Bob'
items.value.push('new item')
<!-- In template: .value is automatic -->
<template>
  <p>{{ count }}</p>
  <button @click="count++">Increment</button>
</template>

reactive — for objects

reactive makes a plain object deeply reactive. No .value needed — destructuring breaks reactivity.

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: { name: 'Alice', age: 30 },
})

state.count++
state.user.name = 'Bob'

// ❌ Destructuring breaks reactivity
const { count } = state  // count is now a plain number

// ✅ Use toRefs to destructure safely
import { toRefs } from 'vue'
const { count, user } = toRefs(state)
count.value++  // reactive again

When to use ref vs reactive

A common rule of thumb:

  • Use ref for primitive values and for refs you pass around (the .value wrapper makes it clear it’s a reactive ref)
  • Use reactive when managing a group of related state that logically belongs together as an object

In practice, many teams prefer ref for everything — the explicitness of .value makes reactivity visible at the call site.


computed

computed creates a cached derived value. It recalculates only when its reactive dependencies change.

import { ref, computed } from 'vue'

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

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

// Writable computed (with get and set)
const fullNameWritable = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (val: string) => {
    const parts = val.split(' ')
    firstName.value = parts[0]
    lastName.value = parts[1] ?? ''
  },
})

// Usage
fullNameWritable.value = 'John Smith'
// firstName.value === 'John', lastName.value === 'Smith'

Never put side effects in computed. Computed properties should be pure derivations — use watch or watchEffect for side effects.


watch and watchEffect

watch — explicit dependency tracking

import { ref, watch } from 'vue'

const query = ref('')
const results = ref<string[]>([])

// Watch a single ref
watch(query, async (newQuery, oldQuery) => {
  if (newQuery.trim() === '') {
    results.value = []
    return
  }
  results.value = await search(newQuery)
})

// Watch multiple sources
const page = ref(1)
watch([query, page], ([newQuery, newPage]) => {
  fetchPage(newQuery, newPage)
})

// Options
watch(query, handler, {
  immediate: true,  // run immediately on mount
  deep: true,       // watch nested object changes
  once: true,       // stop after first run
})

watchEffect — automatic dependency tracking

watchEffect runs immediately and re-runs whenever any reactive value it accesses changes. No need to specify dependencies explicitly.

import { ref, watchEffect } from 'vue'

const userId = ref(1)

watchEffect(async () => {
  // Automatically tracks userId — re-runs when it changes
  const data = await fetchUser(userId.value)
  console.log(data)
})

// Stop a watcher manually
const stop = watchEffect(() => { /* ... */ })
stop()  // clean up

watch vs watchEffect:

  • watch is lazy (opt-in with immediate) and gives you the old and new values — use it for precise, targeted reactions
  • watchEffect is eager and tracks dependencies automatically — use it when you just want to synchronise a side effect

Lifecycle hooks

All Options API lifecycle hooks have Composition API equivalents with an on prefix:

import { onMounted, onUpdated, onUnmounted, onBeforeMount } from 'vue'

onBeforeMount(() => {
  // Runs before the component is mounted to the DOM
})

onMounted(() => {
  // DOM is now available — safe to access refs, start timers, fetch data
  fetchData()
})

onUpdated(() => {
  // Called after a reactive update caused a re-render
})

onUnmounted(() => {
  // Clean up: clear timers, remove event listeners, cancel requests
  clearInterval(timer)
  window.removeEventListener('resize', handleResize)
})

Multiple hooks of the same type can be registered — they run in registration order:

onMounted(() => console.log('first'))
onMounted(() => console.log('second'))
// Output: first, second

Composables — reusable logic

Composables are the Composition API’s answer to mixins. A composable is a plain function that uses Vue’s reactivity API internally. By convention, composables are named use*.

// composables/useWindowSize.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindowSize() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)

  function update() {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }

  onMounted(() => window.addEventListener('resize', update))
  onUnmounted(() => window.removeEventListener('resize', update))

  return { width, height }
}
<!-- In any component -->
<script setup lang="ts">
import { useWindowSize } from '@/composables/useWindowSize'

const { width, height } = useWindowSize()
</script>

<template>
  <p>{{ width }} × {{ height }}</p>
</template>

Async composables with loading and error state

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

export function useAsync<T>(fn: () => Promise<T>) {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  async function execute() {
    loading.value = true
    error.value = null
    try {
      data.value = await fn()
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      loading.value = false
    }
  }

  return { data, loading, error, execute }
}
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAsync } from '@/composables/useAsync'

const { data: users, loading, error, execute } = useAsync(() => fetchUsers())

onMounted(execute)
</script>

TypeScript integration with <script setup>

<script setup lang="ts"> is the recommended way to write typed Vue 3 components. Props and emits are defined with type-only declarations:

<script setup lang="ts">
// Props with default values
interface Props {
  title: string
  count?: number
  items: string[]
}

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

// Emits
const emit = defineEmits<{
  update: [value: string]
  close: []
  change: [id: number, name: string]
}>()

// Expose to parent via template ref
defineExpose({ reset })
</script>

Typing template refs

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

const inputEl = ref<HTMLInputElement | null>(null)

onMounted(() => {
  inputEl.value?.focus()
})
</script>

<template>
  <input ref="inputEl" type="text" />
</template>

provide and inject

provide / inject allow ancestor components to pass data down to any descendant without prop drilling:

// Parent (or plugin)
import { provide, ref } from 'vue'

const theme = ref<'light' | 'dark'>('light')
provide('theme', theme)

// Any descendant
import { inject, type Ref } from 'vue'

const theme = inject<Ref<'light' | 'dark'>>('theme')

Use a typed injection key to avoid string typos:

// keys.ts
import type { InjectionKey, Ref } from 'vue'
export const themeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')

// Provider
provide(themeKey, theme)

// Consumer — fully typed, no casting needed
const theme = inject(themeKey)

Common patterns

Debounced search input

import { ref, watch } from 'vue'

const query = ref('')
const debouncedQuery = ref('')
let timer: ReturnType<typeof setTimeout>

watch(query, (val) => {
  clearTimeout(timer)
  timer = setTimeout(() => {
    debouncedQuery.value = val
  }, 300)
})

Local storage sync

// composables/useLocalStorage.ts
import { ref, watch } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key)
  const value = ref<T>(stored !== null ? JSON.parse(stored) : defaultValue)

  watch(
    value,
    (newVal) => localStorage.setItem(key, JSON.stringify(newVal)),
    { deep: true }
  )

  return value
}

Migration from Options API

If you’re migrating an existing Options API component, the mapping is straightforward:

Options APIComposition API
data()ref() / reactive()
computed: {}computed()
methods: {}plain functions
watch: {}watch() / watchEffect()
mounted()onMounted()
created()top-level code in <script setup>
props: {}defineProps<Props>()
emits: []defineEmits<{...}>()
mixins: []composables (use* functions)

Frequently Asked Questions

Can I mix Options API and Composition API in the same component?

Yes — using setup() within an Options API component is valid. However, in a <script setup> component you cannot use the component options object. For new components, stick to one or the other; don’t mix.

What’s the difference between ref and shallowRef?

ref makes the value deeply reactive — mutating a nested object property triggers updates. shallowRef only tracks changes to .value itself. Use shallowRef for large objects or arrays where you always replace the entire value rather than mutating it in place.

Why does my computed property not update when I mutate a nested object?

If you’re using ref with an object value, mutating a nested property (obj.value.nested.field = 'new') does trigger updates because ref wraps its value with reactive internally. The common mistake is replacing .value with a non-reactive plain object reference outside of a reactive context.

When should I use watchEffect instead of watch?

Use watchEffect when you want a side effect that runs immediately and whenever any reactive value it reads changes — useful for synchronisation logic where explicitly listing dependencies would be verbose. Use watch when you need the old value, want lazy execution, or need fine control over when re-runs happen.