Composition API
Patterns and best practices for Vue 3 Composition API including refs, reactive, computed, watchers, and lifecycle hooks
You are an expert in Vue 3 Composition API patterns for building scalable, maintainable Vue applications. ## Key Points - **Watchers That Modify Their Own Source** — creating `watch` callbacks that mutate the value they are watching without a guard condition, causing infinite update loops that crash the browser tab. - **Accessing Template Refs at Script Top Level** — reading `ref.value` on a template ref before `onMounted`, which always returns `null`. Template refs are only populated after the component mounts. - **`ref`** — wraps a single value in a reactive container; access via `.value` in script, auto-unwrapped in templates - **`reactive`** — creates a deeply reactive proxy of an object; no `.value` needed but cannot reassign the root - **`shallowRef` / `shallowReactive`** — reactive only at the top level, useful for large objects or external data - **`toRefs` / `toRef`** — converts reactive object properties to individual refs while preserving reactivity - **`computed`** — derives a cached reactive value that updates only when dependencies change - **`watch`** — watches one or more reactive sources and runs a callback on change - **`watchEffect`** — runs a side effect immediately, auto-tracking all reactive dependencies accessed during execution 1. **Prefer `<script setup>`** — it is more concise, has better TypeScript inference, and better runtime performance than the Options API or raw `setup()` function. 2. **Use `ref` for primitives, `reactive` for grouped state** — avoid mixing them inconsistently within a component. 3. **Extract logic into composables** — when a component grows beyond ~100 lines of script, split logical concerns into composable functions.
skilldb get vue-skills/Composition APIFull skill: 177 linesComposition API — Vue.js
You are an expert in Vue 3 Composition API patterns for building scalable, maintainable Vue applications.
Core Philosophy
The Composition API exists to solve a specific problem: as components grow, option-based organization (data, methods, computed, watch) scatters related logic across the file, making it hard to follow a single concern. The Composition API flips this by letting you organize code by logical concern — all the search logic together, all the pagination logic together — regardless of whether it involves state, computed values, or lifecycle hooks. This is not just a stylistic preference; it fundamentally changes how you reason about component complexity.
Reactivity in Vue 3 is explicit and granular. You choose ref for individual values and reactive for grouped objects, you explicitly declare what to watch, and you explicitly define computed derivations. This explicitness is a feature: it makes the reactive dependency graph visible in the code rather than hidden behind framework magic. When something updates, you can trace exactly why by following the refs and watchers.
The <script setup> syntax is the canonical way to write Composition API code. It removes ceremony, improves TypeScript inference, and compiles to more efficient code. Treating it as the default rather than an alternative is important — it is the API surface that Vue's tooling, documentation, and ecosystem are optimized for.
Anti-Patterns
-
Mixing Options API and Composition API Arbitrarily — using
data()andsetup()in the same component without a migration rationale creates confusion about where state lives and how reactivity flows. Commit to one approach per component. -
Overusing
reactivefor Everything — reaching forreactive({})when individualrefvalues would be clearer. Reactive objects cannot be reassigned at the root and lose reactivity when destructured, making them subtly error-prone for simple state. -
Watchers That Modify Their Own Source — creating
watchcallbacks that mutate the value they are watching without a guard condition, causing infinite update loops that crash the browser tab. -
Forgetting Cleanup in watchEffect — setting up timers, event listeners, or abort controllers inside
watchEffectwithout returning a cleanup function fromonCleanup, leading to resource leaks every time the effect re-runs. -
Accessing Template Refs at Script Top Level — reading
ref.valueon a template ref beforeonMounted, which always returnsnull. Template refs are only populated after the component mounts.
Overview
The Composition API is Vue 3's primary API for organizing component logic by logical concern rather than option type. It provides fine-grained reactivity primitives (ref, reactive, computed, watch) and lifecycle hooks that can be composed into reusable functions.
Core Concepts
Reactivity Primitives
ref— wraps a single value in a reactive container; access via.valuein script, auto-unwrapped in templatesreactive— creates a deeply reactive proxy of an object; no.valueneeded but cannot reassign the rootshallowRef/shallowReactive— reactive only at the top level, useful for large objects or external datatoRefs/toRef— converts reactive object properties to individual refs while preserving reactivity
Computed and Watchers
computed— derives a cached reactive value that updates only when dependencies changewatch— watches one or more reactive sources and runs a callback on changewatchEffect— runs a side effect immediately, auto-tracking all reactive dependencies accessed during execution
Lifecycle Hooks
onMounted, onUpdated, onUnmounted, onBeforeMount, onBeforeUpdate, onBeforeUnmount, onActivated, onDeactivated, onErrorCaptured
Implementation Patterns
Basic Component with <script setup>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
interface User {
id: number
name: string
email: string
}
const users = ref<User[]>([])
const search = ref('')
const filteredUsers = computed(() =>
users.value.filter(u =>
u.name.toLowerCase().includes(search.value.toLowerCase())
)
)
onMounted(async () => {
const res = await fetch('/api/users')
users.value = await res.json()
})
</script>
<template>
<input v-model="search" placeholder="Search users..." />
<ul>
<li v-for="user in filteredUsers" :key="user.id">
{{ user.name }} — {{ user.email }}
</li>
</ul>
</template>
Reactive Object vs Ref
import { ref, reactive } from 'vue'
// Use ref for primitives and values you may reassign
const count = ref(0)
count.value++
// Use reactive for objects where you mutate properties
const form = reactive({
name: '',
email: '',
errors: {} as Record<string, string>,
})
form.name = 'Alice'
// Avoid: reassigning a reactive root loses reactivity
// form = { name: 'Bob', email: '', errors: {} } // WRONG
Object.assign(form, { name: 'Bob', email: '', errors: {} }) // OK
Watcher Patterns
import { ref, watch, watchEffect } from 'vue'
const query = ref('')
const debouncedQuery = ref('')
// Watch a specific source with options
watch(query, (newVal, oldVal) => {
console.log(`Changed from "${oldVal}" to "${newVal}"`)
}, { immediate: false })
// Watch multiple sources
watch([query, debouncedQuery], ([q, dq]) => {
console.log(q, dq)
})
// watchEffect auto-tracks dependencies
watchEffect((onCleanup) => {
const timer = setTimeout(() => {
debouncedQuery.value = query.value
}, 300)
onCleanup(() => clearTimeout(timer))
})
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" />
</template>
Best Practices
- Prefer
<script setup>— it is more concise, has better TypeScript inference, and better runtime performance than the Options API or rawsetup()function. - Use
reffor primitives,reactivefor grouped state — avoid mixing them inconsistently within a component. - Extract logic into composables — when a component grows beyond ~100 lines of script, split logical concerns into composable functions.
- Type your refs — use
ref<Type>()generics rather than relying on inference for complex types. - Use
watchEffectfor syncing side effects andwatchwhen you need the old value or explicit source control. - Return cleanup from
watchEffectviaonCleanupto prevent memory leaks from timers, subscriptions, or abort controllers. - Avoid deep watchers on large objects — use
shallowRefor watch specific properties instead.
Common Pitfalls
- Forgetting
.value— in<script>, refs require.value; in templates they do not. TypeScript helps catch this but it remains the most common mistake. - Destructuring reactive objects —
const { name } = reactive({ name: 'A' })loses reactivity. UsetoRefs()to destructure safely. - Reassigning reactive roots —
state = newObjreplaces the proxy. UseObject.assignor replace individual properties. - Infinite watch loops — mutating a watched source inside its own watcher without a guard condition causes infinite recursion.
- Accessing template refs before mount — template refs are
nulluntilonMounted; never access them at the top level of<script setup>. - Forgetting to handle async in
watchEffect—watchEffect(async () => { ... })does not track dependencies after the firstawait. Declare reactive reads before anyawait.
Install this skill directly: skilldb add vue-skills
Related Skills
Composables
Building reusable custom composables in Vue 3 with proper typing, lifecycle management, and state sharing
Nuxt Routing
Nuxt 3 file-based routing, dynamic routes, layouts, middleware, and navigation patterns
Nuxt Server
Nuxt 3 server routes, API endpoints, server middleware, and Nitro engine patterns for full-stack development
Pinia
Pinia state management for Vue 3 including store design, actions, getters, plugins, and SSR hydration
Provide Inject
Vue 3 provide/inject for dependency injection, deeply nested component communication, and plugin design
Vue Router
Vue Router 4 advanced patterns including navigation guards, lazy loading, scroll behavior, and typed routes