Massive setup() functions, reactive() where ref() belongs, watchers that fire on mount when they shouldn’t, composables that aren’t composable, and losing reactivity without knowing why. The Composition API is better than Options API — but it has its own failure modes.
The Composition API was the right answer to the Options API’s limitations. Logic scattered across data, computed, watch, and methods by category rather than concern. Mixins that created invisible dependencies and namespace collisions. Components that couldn’t share stateful logic without architectural contortions.
Composition API solved all of that. It’s genuinely better.
But “better” doesn’t mean “can’t be done wrong.” The Composition API has its own failure modes — patterns that feel natural in the moment and cause real problems later. The difference from Options API is that the Composition API failures tend to be more subtle: they don’t break things immediately, they accumulate silently and make code progressively harder to understand, test, and maintain.
This post covers the patterns I see most often in Vue 3 codebases and what to do instead.
Mistake 1: The Massive setup() Function
The Options API scattered logic across categories (data, computed, methods). The Composition API offers the ability to group logic by concern — to put the data fetching code, the computed properties that derive from it, and the functions that update it all in one place.
The most common failure mode: developers move everything from the Options API into a single setup() function without grouping it at all. The result is a 300-line <script setup> block that’s as hard to navigate as the Options API was, just in a different shape.
<!-- ✗ The 300-line script setup — the same old problem, new format -->
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
// Product state
const products = ref([])
const productLoading = ref(false)
const productError = ref(null)
const selectedProduct = ref(null)
// Filter state
const searchQuery = ref('')
const categoryFilter = ref(null)
const priceRange = ref([0, 10000])
const sortBy = ref('newest')
// Pagination state
const currentPage = ref(1)
const perPage = ref(20)
const totalProducts = ref(0)
// Cart state
const cartItems = ref([])
const cartOpen = ref(false)
// Modal state
const quickViewProduct = ref(null)
const isQuickViewOpen = ref(false)
// Computed properties — all mixed together
const filteredProducts = computed(() => { /* ... */ })
const totalPages = computed(() => Math.ceil(totalProducts.value / perPage.value))
const cartItemCount = computed(() => cartItems.value.length)
const cartTotal = computed(() => cartItems.value.reduce(/* ... */, 0))
// All the functions, also all mixed together
async function fetchProducts() { /* ... */ }
function addToCart(product) { /* ... */ }
function removeFromCart(id) { /* ... */ }
function openQuickView(product) { /* ... */ }
function closeQuickView() { /* ... */ }
function updateFilters() { /* ... */ }
// ... 15 more functions
// Watchers
watch(searchQuery, fetchProducts)
watch(categoryFilter, fetchProducts)
onMounted(() => { fetchProducts() })
</script>
The Composition API is not a replacement for organisation — it’s a tool that enables better organisation. The actual solution is composables:
<!-- ✓ Slim script setup — delegates to composables -->
<script setup lang="ts">
import { useProductCatalogue } from '@/composables/useProductCatalogue'
import { useProductFilters } from '@/composables/useProductFilters'
import { useShoppingCart } from '@/composables/useShoppingCart'
import { useProductModals } from '@/composables/useProductModals'
const filters = useProductFilters()
const catalogue = useProductCatalogue(filters)
const cart = useShoppingCart()
const modals = useProductModals()
</script>
The script setup is now four lines. Each concern has a home. Any developer can open useProductFilters.ts and understand the entire filter system without reading about the cart, the pagination, or the modals.
Mistake 2: reactive() for Everything (and Losing Reactivity from Destructuring)
<!-- ✗ Using reactive() for all state -->
<script setup lang="ts">
import { reactive } from 'vue'
const state = reactive({
user: null,
loading: false,
error: null,
products: [],
page: 1,
})
// Somewhere else in the same file, or in a composable:
const { user, loading } = state // ← reactivity LOST — both are now plain values
</script>
This is the reactive() footgun. Destructuring a reactive object produces plain values — not refs. user and loading are now primitive values disconnected from the reactivity system. Mutating user does nothing. The template won’t update.
<!-- ✓ Use ref() for individual values — no footgun -->
<script setup lang="ts">
import { ref } from 'vue'
const user = ref(null)
const loading = ref(false)
const error = ref(null)
const products = ref([])
const page = ref(1)
// Destructuring is not needed — you access .value directly
// Nothing to accidentally break
</script>
<!-- ✓ If you need reactive() with destructuring, use toRefs() -->
<script setup lang="ts">
import { reactive, toRefs } from 'vue'
const state = reactive({ user: null, loading: false })
// toRefs() converts properties to refs — safe to destructure
const { user, loading } = toRefs(state)
user.value = fetchedUser // ✓ reactive
</script>
When reactive() Is Actually Appropriate
// reactive() is genuinely useful for grouped, tightly-related state
// where you'll never destructure
const formState = reactive({
firstName: '',
lastName: '',
email: '',
phone: '',
})
// Passing the entire object to a form submission
async function submit() {
await api.post('/users', formState) // spread the whole object, never destructure
}
The rule: use ref() by default. Reach for reactive() only when you have a group of tightly related values you’ll always use together as an object — and only when you won’t destructure.
Mistake 3: Watchers That Fire on Mount When They Shouldn’t
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
const userId = ref(1)
const user = ref(null)
// Two patterns that duplicate work or create subtle bugs:
// ✗ Pattern A: watcher with immediate: true + separate onMounted
onMounted(async () => {
user.value = await fetchUser(userId.value) // runs on mount
})
watch(userId, async (id) => {
user.value = await fetchUser(id) // runs when userId changes
})
// Problem: on mount, BOTH run. The first fetch is immediately replaced
// by the second before it completes. Two fetches for no reason.
// ✗ Pattern B: immediate watcher + no cleanup
watch(userId, async (id) => {
user.value = await fetchUser(id) // runs on mount AND on change
}, { immediate: true })
// Problem: no cleanup — stale fetches can overwrite newer data
</script>
<!-- ✓ watchEffect handles both cases correctly -->
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
const userId = ref(1)
const user = ref(null)
// Runs immediately on mount, then whenever userId changes
// Auto-tracks dependencies — no [userId] array needed
// onCleanup cancels stale requests
watchEffect(async (onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const response = await fetch(`/api/users/${userId.value}`, {
signal: controller.signal,
})
user.value = await response.json()
})
</script>
When watch() Is Correct
watch is the right tool when you need:
- The old value alongside the new value
- Explicit control over which dependencies trigger the watcher
- Lazy behaviour (not running on first mount) without special handling
// ✓ watch() is correct when you need the old value
watch(userId, (newId, oldId) => {
console.log(`User changed from ${oldId} to ${newId}`)
trackUserSwitch(oldId, newId)
})
// ✓ watch() is correct for explicit lazy behaviour
// (watchEffect always runs on mount — watch doesn't without immediate: true)
watch(confirmationCode, (code) => {
if (code.length === 6) verifyCode(code)
})
// Should NOT run on mount — only when the user enters a code
Mistake 4: Composables That Aren’t Actually Composable
The name “composable” implies that these functions can be used together, combined, and reused. In practice, many composables are written in ways that make them fragile, hard to reuse, or impossible to compose.
Anti-pattern: Hardcoded API URLs Inside Composables
// ✗ Composable with hardcoded concerns — not reusable
export function useUser() {
const user = ref(null)
async function fetch() {
// Hardcoded URL — this composable is only useful for one specific endpoint
const res = await api.get('/api/users/me')
user.value = res.data
}
onMounted(fetch)
return { user, refetch: fetch }
}
// ✓ Composable that accepts what it needs — genuinely reusable
export function useFetch<T>(url: string | Ref<string>) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
watchEffect(async (onCleanup) => {
const endpoint = isRef(url) ? url.value : url
const controller = new AbortController()
onCleanup(() => controller.abort())
loading.value = true
error.value = null
try {
const res = await fetch(endpoint, { signal: controller.signal })
data.value = await res.json()
} catch (err) {
if ((err as Error).name !== 'AbortError') {
error.value = (err as Error).message
}
} finally {
loading.value = false
}
})
return { data, loading, error }
}
// Used for anything
const { data: user } = useFetch<User>('/api/users/me')
const { data: products } = useFetch<Product[]>('/api/products')
const { data: orders } = useFetch<Order[]>(computed(() => `/api/users/${userId.value}/orders`))
Anti-pattern: Composable With Side Effects It Doesn’t Clean Up
// ✗ Composable that adds a listener but never removes it
export function useScrollPosition() {
const scrollY = ref(0)
// Added every time the composable is called, never removed
window.addEventListener('scroll', () => {
scrollY.value = window.scrollY
})
return { scrollY }
}
// Every component that calls useScrollPosition() adds another listener
// None are ever removed — classic memory leak
// ✓ Composable that cleans up after itself
export function useScrollPosition() {
const scrollY = ref(0)
const handler = () => { scrollY.value = window.scrollY }
onMounted(() => window.addEventListener('scroll', handler, { passive: true }))
onUnmounted(() => window.removeEventListener('scroll', handler))
return { scrollY: readonly(scrollY) }
}
Anti-pattern: Composable That Does Too Much
// ✗ "Composable" that's actually a god object
export function useProductPage() {
// Fetches products
// Manages filters
// Handles pagination
// Controls modals
// Manages cart
// Tracks analytics
// Handles URL sync
// ...
return {
products, filters, pagination, modals, cart,
openModal, closeModal, addToCart, updateFilter,
// 20 more things
}
}
A composable that takes more than one paragraph to describe what it does needs to be split. The single responsibility principle applies here as much as it does to classes.
Mistake 5: Overusing Computed Properties for Side Effects
<script setup lang="ts">
import { ref, computed } from 'vue'
const userId = ref(1)
// ✗ Computed property with a side effect — WRONG
const user = computed(() => {
fetchUser(userId.value) // this runs on every render that accesses user.value
return cachedUser.value // and it returns stale data
})
// ✗ Computed property that "works" but is really a watcher
const formattedUser = computed(() => {
// This is pure derivation — this is CORRECT usage
return `${user.value?.firstName} ${user.value?.lastName}`
})
<!-- ✓ Computed for derivation, watch/watchEffect for side effects -->
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
const userId = ref(1)
const user = ref(null)
// ✓ watchEffect for the side effect (fetching)
watchEffect(async (onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
user.value = await fetchUser(userId.value, controller.signal)
})
// ✓ computed for pure derivation (no side effects)
const fullName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
)
</script>
The rule: computed properties must be pure functions. Same input → same output, no side effects. If you find yourself doing anything in a computed that affects state outside the computed (API calls, logging, mutations), move it to a watcher.
Mistake 6: Not Using readonly() to Prevent Accidental Mutation
When you expose reactive state from a composable, consumers can accidentally mutate it. For state that should only change through defined functions, readonly() communicates the intent and prevents bugs.
// ✗ Exposing mutable state directly — consumer can mutate it
export function useNotifications() {
const notifications = ref<Notification[]>([])
return {
notifications, // consumer could do notifications.value.push(anything)
add: (n: Notification) => notifications.value.push(n),
remove: (id: string) => notifications.value = notifications.value.filter(n => n.id !== id),
}
}
// ✓ Exposing readonly state — mutations go through defined functions only
export function useNotifications() {
const notifications = ref<Notification[]>([])
function add(notification: Omit<Notification, 'id'>) {
notifications.value.push({ ...notification, id: crypto.randomUUID() })
}
function remove(id: string) {
notifications.value = notifications.value.filter(n => n.id !== id)
}
return {
notifications: readonly(notifications), // consumer gets a read-only view
add,
remove,
}
}
Mistake 7: Misunderstanding Reactivity Loss
Reactivity loss is the most common source of “why isn’t my template updating?” bugs in Vue 3.
Replacing a Reactive Object
// ✗ Replacing the entire reactive object loses reactivity
const state = reactive({ count: 0, name: 'Vue' })
// In some function:
Object.assign(state, newData) // ✓ maintains reactivity — merges into existing object
state = newData // ✗ error — can't reassign a const
// But often people do:
const state = reactive({})
state = Object.assign({}, newData) // ✓ compile error stops this
// The real footgun:
let state = reactive({ count: 0 })
state = reactive({ count: 1 }) // replaces the reference — old template binding is stale
// ✓ Use Object.assign to update reactive objects in-place
const state = reactive({ count: 0, name: 'Vue' })
Object.assign(state, { count: 1, name: 'React' }) // updates in-place, reactivity maintained
The ref in a Reactive Object Unwrapping Behaviour
// Vue automatically unwraps refs when they're nested in reactive objects
const count = ref(0)
const state = reactive({ count })
// In a reactive object: no .value needed
state.count // → 0 (auto-unwrapped)
state.count = 1 // ✓ works, updates count.value
// But outside a reactive object: .value required
count.value // → 1 (matches state.count)
This auto-unwrapping is convenient but confusing. If you mix refs and reactive objects, it becomes difficult to know when .value is required. Prefer one pattern consistently.
Reactive Refs in Template vs Script
<script setup lang="ts">
const count = ref(0)
</script>
<template>
<!-- Template auto-unwraps refs — no .value needed in template -->
<p>{{ count }}</p> <!-- ✓ correct — auto-unwrapped -->
<p>{{ count.value }}</p> <!-- ✗ also works but wrong — .value is undefined in template context-->
</template>
In templates, refs are auto-unwrapped — you write {{ count }} not {{ count.value }}. In <script setup>, refs require .value. The boundary is the <template> tag.
Mistake 8: Lifecycle Hooks Outside setup() Context
// ✗ Calling lifecycle hooks outside a component's setup context
export async function fetchDataAndSetupHooks() {
// Called from inside setup() — this works
onMounted(() => console.log('mounted'))
const data = await fetchData()
// After the await — setup context is gone!
onMounted(() => console.log('this never fires'))
// Warning: onMounted is called when there is no active component instance
}
// ✓ Register all lifecycle hooks before any awaits
export function useDataFetcher() {
const data = ref(null)
const loading = ref(false)
// All hooks registered synchronously before any async work
onMounted(async () => {
loading.value = true
data.value = await fetchData()
loading.value = false
})
onUnmounted(() => {
// cleanup
})
return { data, loading }
}
Lifecycle hooks must be called synchronously during the component’s setup() execution. After the first await, the setup context is no longer active — hooks called after an await are silently ignored.
Mistake 9: Returning Too Much From Composables
Every value returned from a composable becomes part of the consumer’s interface. Returning everything makes composables harder to use, harder to understand, and exposes internal implementation details that should be private.
// ✗ Exposing internal implementation details
export function useProductSearch() {
const rawQuery = ref('') // internal debounce input
const debouncedQuery = ref('') // internal debounced version
const cancelToken = ref(null) // internal cancellation ref
const lastRequestId = ref(0) // internal request ID
const products = ref([])
const loading = ref(false)
const error = ref(null)
return {
// Everything returned — consumer sees internal implementation
rawQuery, debouncedQuery, cancelToken, lastRequestId,
products, loading, error,
search,
}
}
// ✓ Return only what consumers need — keep internals private
export function useProductSearch() {
// Private — not exposed
const rawQuery = ref('')
const debouncedQuery = ref('')
const controller = ref<AbortController | null>(null)
// Public — returned to consumer
const products = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function search(query: string) {
rawQuery.value = query
// debounce logic, abort control, etc.
}
return {
products: readonly(products),
loading: readonly(loading),
error: readonly(error),
search,
}
}
The Composition API Correctness Checklist
Run through this before submitting any Vue 3 component or composable:
Script setup organisation:
✓ Large script setups delegate to composables — no 200+ line setup functions
✓ Each composable has one clear responsibility
✓ Composable name starts with "use" and describes the domain
Reactivity choices:
✓ ref() used by default for individual values
✓ reactive() only used for grouped, never-destructured objects
✓ If reactive() is destructured, toRefs() is applied first
✓ readonly() applied to state exposed from composables
Watchers:
✓ watchEffect for "run on mount AND on dependency change" patterns
✓ watch for "old value needed" or "lazy by default" patterns
✓ No immediate: true watcher PLUS separate onMounted for the same data
✓ watchEffect uses onCleanup for async operations with AbortController
Composables:
✓ Browser APIs (window, document) cleaned up in onUnmounted
✓ Lifecycle hooks called synchronously — never after an await
✓ Composables return the minimum necessary interface
✓ Composables accept inputs as arguments — no hardcoded endpoints or IDs
Computed properties:
✓ Every computed is a pure function — no API calls, no state mutations
✓ Side effects are in watchers, not computed properties
Template reactivity:
✓ No .value in template — refs are auto-unwrapped
✓ Reactive object properties accessed directly — no toRefs needed in template
Final Thoughts
The Composition API is a genuine improvement over the Options API. But improvement is not immunity to misuse. The failure modes are different — and in some ways, more subtle.
The most important principle to internalise: the Composition API gives you tools to organise code by concern, but it doesn’t impose organisation. A 300-line <script setup> is the same problem as a 300-line Options API component, just with different syntax. The Composition API enables composables; it doesn’t enforce them.
The second most important: ref() is the default. reactive() is for specific cases. Most reactive state bugs in Vue 3 trace back to destructuring reactive objects or replacing reactive state instead of updating it in place. Defaulting to ref() eliminates most of them.
The Composition API is capable of producing beautiful, maintainable, testable Vue code. It’s also capable of producing a different set of unmaintainable patterns. The difference is understanding what the tools are designed for — and using each one for the problem it was designed to solve.
