Vue 3 & Composition API
Vue 3 ships two ways to write component logic: the Options API and the Composition API. The Options API organizes code by type (data, methods, computed, watch). The Composition API organizes code by logical concern. The Composition API with script setup is the modern standard, and understanding why it won is essential for writing effective Vue.
Options API vs Composition API
The Options API was Vue 2's default. You define a component as an object with named properties:
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return {
searchQuery: '',
users: [] as { id: number; name: string }[],
loading: false,
}
},
computed: {
filteredUsers() {
return this.users.filter(u =>
u.name.toLowerCase().includes(this.searchQuery.toLowerCase())
)
},
},
methods: {
async fetchUsers() {
this.loading = true
const res = await fetch('/api/users')
this.users = await res.json()
this.loading = false
},
},
mounted() {
this.fetchUsers()
},
})
</script>
This works for small components, but as components grow, related logic gets scattered. The search query is in data, the filtering is in computed, and if you add a debounced search, the watcher goes in watch and the debounce timer goes in data. One feature is spread across four sections.
The Composition API keeps related logic together:
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
const searchQuery = ref('')
const users = ref<{ id: number; name: string }[]>([])
const loading = ref(false)
const filteredUsers = computed(() =>
users.value.filter(u =>
u.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
)
async function fetchUsers() {
loading.value = true
const res = await fetch('/api/users')
users.value = await res.json()
loading.value = false
}
onMounted(fetchUsers)
</script>
All user-related logic lives in one block. If you add a second feature (say, notifications), its code sits in another block in the same file. You can read each feature top to bottom without jumping between sections.
Script Setup: The Modern Syntax
<script setup> is a compile-time syntactic sugar that makes the Composition API less verbose. Without it, you need to explicitly return everything the template uses:
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup() {
const count = ref(0)
function increment() {
count.value++
}
// Must return everything used in template
return { count, increment }
},
})
</script>
With script setup, top-level bindings are automatically available to the template:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
// No return statement needed. count and increment are available in template.
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
Less boilerplate. No defineComponent wrapper. No return statement. Imports are also automatically available in the template: if you import a component, you can use it directly without registration.
Reactive Refs & Computed
ref() creates a reactive reference. It wraps any value in an object with a .value property. In <script setup>, you access .value directly. In templates, Vue auto-unwraps it:
<script setup lang="ts">
import { ref, computed } from 'vue'
const price = ref(29.99)
const quantity = ref(1)
const discount = ref(0.1)
const subtotal = computed(() => price.value * quantity.value)
const total = computed(() => subtotal.value * (1 - discount.value))
</script>
<template>
<div>
<label>
Quantity:
<input v-model.number="quantity" type="number" min="1" />
</label>
<!-- No .value needed in template -->
<p>Subtotal: ${{ subtotal.toFixed(2) }}</p>
<p>Total (after {{ discount * 100 }}% off): ${{ total.toFixed(2) }}</p>
</div>
</template>
computed() creates a derived value that automatically recalculates when its dependencies change. It is lazy (only recalculates when accessed) and cached (returns the cached result if dependencies have not changed).
Watchers
Watchers let you run side effects when reactive state changes:
<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue'
const searchQuery = ref('')
const results = ref<string[]>([])
// watch: explicit about what to observe
watch(searchQuery, async (newQuery, oldQuery) => {
if (newQuery.length < 2) {
results.value = []
return
}
const res = await fetch(`/api/search?q=${newQuery}`)
results.value = await res.json()
})
// watchEffect: automatic dependency tracking
watchEffect(() => {
console.log(`Currently showing ${results.value.length} results`)
})
</script>
watch() takes an explicit source and runs when that source changes. watchEffect() automatically tracks every reactive value read inside it and reruns when any of them change.
Composables: Vue's Reuse Mechanism
Composables are functions that encapsulate reactive logic and return reactive state. They are the Composition API's answer to code reuse, replacing mixins from Vue 2:
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T>
watch(data, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
return data
}
<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage'
const theme = useLocalStorage('theme', 'light')
const fontSize = useLocalStorage('fontSize', 16)
</script>
<template>
<div :class="theme">
<select v-model="theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<input v-model.number="fontSize" type="range" min="12" max="24" />
<p :style="{ fontSize: fontSize + 'px' }">Preview text</p>
</div>
</template>
The composable encapsulates the localStorage sync logic. The component just uses theme and fontSize as reactive values. Change the theme, and it persists to localStorage automatically.
Why Composables Beat Mixins
Vue 2 mixins had serious problems:
- Name conflicts: Two mixins defining the same property silently override each other.
- Implicit dependencies: You cannot tell where a property comes from without reading every mixin.
- No TypeScript support: Mixins are untyped objects merged at runtime.
Composables solve all three:
// No name conflicts: each return value is explicitly named
const { user, login, logout } = useAuth()
const { data, error, loading } = useFetch('/api/data')
// Explicit dependencies: you can see exactly what each composable provides
// Full TypeScript support: return types are inferred
Why Composition API Wins for TypeScript
The Options API requires this context, which TypeScript struggles to type correctly across data, computed, and methods. The Composition API uses plain functions and variables that TypeScript infers naturally:
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Product {
id: number
name: string
price: number
inStock: boolean
}
const products = ref<Product[]>([])
// TypeScript infers this as ComputedRef<Product[]>
const availableProducts = computed(() =>
products.value.filter(p => p.inStock)
)
// TypeScript infers parameter and return types
function findProduct(id: number): Product | undefined {
return products.value.find(p => p.id === id)
}
</script>
No type gymnastics. No this confusion. Just functions and variables with standard TypeScript inference.
Common Pitfalls
- Using Options API in new projects: The Options API still works, but the Composition API with
script setupis the recommended approach for all new Vue 3 code. The ecosystem, documentation, and tooling have all moved to Composition API. - Forgetting
.valuein script:refrequires.valuein JavaScript but auto-unwraps in templates. This is the most common source of bugs for new Vue developers. - Making composables that are too granular: A composable that wraps a single
refadds abstraction without value. Composables should encapsulate meaningful logic with multiple reactive values working together. - Not using
script setup: The plainsetup()function works but requires manual returns and more boilerplate. There is no reason to prefer it overscript setupin new code. - Overusing
watchEffect: When you know exactly what you want to watch, usewatchwith an explicit source.watchEffectis convenient but can trigger unexpectedly when you read reactive values you did not intend to track.
Key Takeaways
- The Composition API organizes code by logical concern, not by option type. Related logic stays together.
script setupeliminates boilerplate: nodefineComponent, noreturnstatement, automatic template availability.ref()creates reactive values,computed()derives cached values,watch()runs side effects.- Composables are reusable functions that encapsulate reactive logic. They replace mixins with explicit, typed, conflict-free code reuse.
- The Composition API provides natural TypeScript inference without the
thiscontext issues of the Options API. - All new Vue 3 projects should use the Composition API with
script setupand TypeScript.