Vue 3’s Composition API Best Practices
Why it matters now: As Vue 3 matures and teams build larger apps, the Composition API offers cleaner organization, better reusability, and stronger TypeScript support, but only when used with intentional patterns

I started using Vue 3’s Composition API on production apps before it was even marked stable. I remember the first time I refactored a sprawling Options API component with 30+ methods and deeply nested watchers. The logic was scattered across lifecycle hooks, computed properties, and a data object that felt like a junk drawer. When I ported it to the Composition API, I didn’t just move code around. I grouped logic by feature, extracted reusable utilities, and finally felt confident extending functionality without breaking unrelated parts. That moment made me a believer, not because the Composition API is trendy, but because it made my work easier.
If you’re reading this, you’re likely asking a few questions. Does the Composition API replace the Options API entirely? Is it only for TypeScript projects? Will it make simple components needlessly complex? What are the patterns that avoid the common pitfalls? This post aims to answer those with practical guidance, grounded in real project experience, not just API docs. You’ll see how to structure files, how to manage reactive state, how to share logic safely, and where the Composition API shines versus where it might be overkill.
If you’re looking for a one sentence takeaway: the Composition API is best used as a tool for organizing logic by domain and enabling reuse, not as an excuse to turn every component into an unstructured script setup.
Context: Where the Composition API fits today
Vue’s Composition API shipped as part of Vue 3 core, and it’s fully supported in Vue 2.7 as well. It’s not a new framework; it’s a different way to compose component logic that pairs naturally with <script setup>, TypeScript, and Vite. In real-world projects, teams use it to:
- Extract and share feature logic across components via composables.
- Isolate side effects and asynchronous code cleanly.
- Improve type inference and maintain long-lived codebases.
- Reduce cognitive overhead in large components by grouping related code.
Compared to the Options API, the Composition API offers more flexible code organization. Instead of scattering logic across options like data, computed, methods, and watch, you colocate related reactive state and behavior. This is particularly useful when a component spans multiple concerns, such as authentication state, form validation, and real-time data subscriptions.
Compared to other frameworks, Vue’s approach is pragmatic. React’s hooks have a similar mental model but come with rules of hooks that can be tricky. Svelte’s reactivity is elegantly implicit but requires adapting to a different compiler model. Angular’s dependency injection and RxJS-heavy patterns are powerful but heavier. The Composition API lands in a sweet spot: explicit reactivity with minimal boilerplate and a gentle learning curve for Vue developers.
Who typically adopts it? Mid-size to large teams building dashboards, data-heavy apps, and component libraries. Solo developers building SPAs appreciate the better organization as projects grow. Even teams maintaining legacy Vue 2 apps benefit from Vue 2.7’s backport. The Composition API is now a first-class citizen, and the ecosystem has fully aligned around it.
Core concepts: From mental model to patterns
The Composition API revolves around ref and reactive for state, computed for derived values, watch and watchEffect for side effects, and lifecycle hooks like onMounted used inside setup functions. In <script setup>, much of the boilerplate disappears, but the underlying principles remain.
A simple mental model: treat your component as a function that returns behavior and state. The function runs once, creates reactive values, and sets up watchers. Vue’s reactivity tracks dependencies and updates the DOM when needed. The trick is to write code that respects reactivity rules while avoiding unnecessary complexity.
Here’s a minimal, real-world pattern: a feature composable that manages fetching a resource, pagination, and error handling. This pattern appears often in dashboards and lists.
// composables/usePagedFetch.ts
import { ref, watchEffect } from 'vue'
interface Item {
id: number
name: string
}
export function usePagedFetch(endpoint: string, pageSize = 20) {
const items = ref<Item[]>([])
const page = ref(1)
const loading = ref(false)
const error = ref<Error | null>(null)
async function loadPage() {
loading.value = true
error.value = null
try {
const res = await fetch(`${endpoint}?page=${page.value}&size=${pageSize}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
items.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e : new Error('Unknown error')
} finally {
loading.value = false
}
}
watchEffect(() => {
// Runs when page or endpoint changes
loadPage()
})
return {
items,
page,
loading,
error,
nextPage: () => page.value++,
prevPage: () => page.value--,
reload: () => loadPage()
}
}
Notice a few things. We return reactive refs so the caller can bind to the template. We use watchEffect to run loadPage whenever dependencies change. Error handling is centralized, and loading state is explicit. This pattern scales well because it’s just a function that encapsulates async logic and state. In a real app, you’d add cancellation with AbortController and debounce if needed, but the skeleton remains.
Practical patterns for large components
When a component grows, avoid a single 300-line setup block. Group logic by feature. I like to use “setup zones” and comment sections that delineate concerns: state, computed, watchers, methods, and lifecycle.
Here’s a snippet showing a profile component with authentication and form validation:
// components/UserProfile.vue
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { useProfile } from '@/composables/useProfile'
import { required, minLength, validate } from '@/utils/validators'
const { user, updateEmail } = useAuth()
const { profile, saveProfile } = useProfile()
// ----- State -----
const email = ref(user.value?.email ?? '')
const displayName = ref(profile.value?.displayName ?? '')
const bio = ref(profile.value?.bio ?? '')
const saving = ref(false)
const message = ref('')
// ----- Validation -----
const errors = computed(() => ({
email: validate(email.value, [required, (v) => v.includes('@')]),
displayName: validate(displayName.value, [required, minLength(3)])
}))
const isValid = computed(() => {
return Object.values(errors.value).every((e) => e === true)
})
// ----- Actions -----
async function onSubmit() {
if (!isValid.value) return
saving.value = true
message.value = ''
try {
await Promise.all([
updateEmail(email.value),
saveProfile({ displayName: displayName.value, bio: bio.value })
])
message.value = 'Profile updated'
} catch (e) {
message.value = 'Failed to save profile'
} finally {
saving.value = false
}
}
// ----- Lifecycle / Watchers -----
watch(user, () => {
if (user.value) email.value = user.value.email
}, { immediate: true })
</script>
<template>
<form @submit.prevent="onSubmit">
<label>
Email
<input v-model="email" type="email" />
<span v-if="errors.email !== true">{{ errors.email }}</span>
</label>
<label>
Display name
<input v-model="displayName" />
<span v-if="errors.displayName !== true">{{ errors.displayName }}</span>
</label>
<label>
Bio
<textarea v-model="bio"></textarea>
</label>
<button :disabled="!isValid || saving">{{ saving ? 'Saving...' : 'Save' }}</button>
<p v-if="message">{{ message }}</p>
</form>
</template>
This shows a clear separation: state at the top, validation computed, actions below, and watchers at the end. In the template, we avoid calling functions that mutate state directly; everything is bound to refs or computed values. This reduces subtle bugs. The composable pattern (useAuth, useProfile) makes this component testable and keeps side effects isolated.
Managing async flows and cancellation
Async code deserves special attention. In real apps, network requests race, users navigate away, and state updates should be guarded. I often use an AbortController to cancel stale requests, especially in search inputs or paginated lists.
// composables/useSearch.ts
import { ref, watch, onUnmounted } from 'vue'
export function useSearch(endpoint: string) {
const query = ref('')
const results = ref<any[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
let controller: AbortController | null = null
const runSearch = async (q: string) => {
controller?.abort()
controller = new AbortController()
const { signal } = controller
loading.value = true
error.value = null
try {
const res = await fetch(`${endpoint}?q=${encodeURIComponent(q)}`, { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
results.value = await res.json()
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
// ignore, it's a stale request
return
}
error.value = e instanceof Error ? e : new Error('Unknown error')
} finally {
loading.value = false
}
}
watch(query, (q) => {
if (q.length < 3) {
results.value = []
return
}
runSearch(q)
})
onUnmounted(() => {
controller?.abort()
})
return { query, results, loading, error }
}
A few patterns emerge:
- Abort stale requests to avoid out-of-order updates.
- Skip requests until input reaches a sensible threshold.
- Clean up on unmount to avoid memory leaks.
This is the kind of code that feels small but prevents a class of bugs that are hard to reproduce in tests but annoying in production.
TypeScript and reactivity pitfalls
TypeScript is not required, but it pairs beautifully with the Composition API. Refs can be tricky to type when they’re initialized as null. Instead of fighting with any, use a type assertion or set an initial value that matches the eventual shape.
Common mistake:
// Avoid: type is Ref<User | null>, but you keep checking everywhere
const user = ref<User | null>(null)
Better patterns:
- Initialize with a sensible empty value if possible:
const user = ref<User>({ id: 0, name: '' }). - Use utility types in composables:
const loading = ref<boolean>(false). - When fetching, narrow types immediately:
const data = await res.json() as User.
Another frequent issue is accidentally creating a ref inside a reactive object. reactive unwraps refs, so nesting refs inside it can be confusing. Prefer reactive for object state and ref for primitives or when you need to reassign the entire value.
// Good
const state = reactive({ count: 0, name: 'Alice' })
const total = ref(0)
// Avoid
const bad = reactive({ count: ref(0) }) // count is unwrapped, different mental model
When to use the Options API vs the Composition API
In small, static components, the Options API is still perfectly fine. If a component has two computed properties and a click handler, there’s no need to refactor. The Composition API shines in:
- Components with multiple concerns (filters, pagination, subscriptions).
- Reusable business logic extracted as composables.
- TypeScript-heavy codebases.
- Complex state interactions and watchers.
I’ve seen teams convert too aggressively. The result was overly abstract code where every tiny helper became a composable. That adds indirection. Use the Composition API to reduce complexity, not add layers for their own sake.
Personal experience: lessons from the trenches
In one project, we built a real-time analytics dashboard. Each widget needed to subscribe to a WebSocket, handle backpressure, and update charts without jank. The Options API would have required multiple lifecycle hooks and data structures that were hard to share. With the Composition API, we created useSocket and useSeriesData composables.
A turning point came when we realized that watchEffect was firing too often, recomputing derived data on every minor change. We switched to watch with explicit dependencies and used computed properties for derived values. The dashboard became smoother, and bugs related to ordering of updates vanished.
Another lesson was around cleanup. In one version, we forgot to unsubscribe from WebSocket on unmount. It only surfaced when users left tabs open and came back hours later, causing multiple streams. Adding onUnmounted to useSocket fixed it. Since then, I always add cleanup hooks to any composable that registers listeners or timers.
A final observation: the learning curve is gentle for Vue developers, but the subtlety is in writing “stable” reactivity. Avoid creating reactive objects inside loops, don’t mutate refs in computed, and prefer explicit watchers over side effects in computed functions. These are small rules that pay off.
Getting started: project setup and mental model
For a new project, I recommend Vite and Vue with TypeScript. It’s fast, and the default tooling includes vue-tsc for type checking.
A typical project structure looks like this:
src/
components/
UserProfile.vue
DashboardWidget.vue
composables/
useAuth.ts
usePagedFetch.ts
useSearch.ts
useSocket.ts
views/
HomeView.vue
DashboardView.vue
router/
index.ts
utils/
validators.ts
App.vue
main.ts
The mental model: keep components in components as UI units, views as page-level containers, composables as reusable logic, and utils as pure helpers. If a composable grows too large, split it by domain.
Here’s a minimal main.ts for context:
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App)
.use(router)
.mount('#app')
For routing, keep route guards lightweight and prefer composables for shared data loading. In a real app, I like to use a useRouteGuard composable to centralize authentication checks and redirects.
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
import DashboardView from '@/views/DashboardView.vue'
import { useAuth } from '@/composables/useAuth'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: HomeView },
{ path: '/dashboard', component: DashboardView, meta: { requiresAuth: true } }
]
})
router.beforeEach((to, from) => {
const { isAuthenticated } = useAuth()
if (to.meta.requiresAuth && !isAuthenticated.value) {
return { path: '/' }
}
})
Because composables can be used inside router guards, you avoid duplicating logic and keep the guard concise.
Testing and maintaining composables
Testing composables is straightforward. Since they’re plain functions, you can call them inside a test setup and assert returned refs. Here’s a quick example using Vitest:
// composables/__tests__/usePagedFetch.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { usePagedFetch } from '../usePagedFetch'
import { nextTick } from 'vue'
const mockFetch = vi.fn()
global.fetch = mockFetch
describe('usePagedFetch', () => {
beforeEach(() => {
mockFetch.mockReset()
})
it('loads items and updates state', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => [{ id: 1, name: 'Item 1' }]
} as Response)
const { items, loading, error } = usePagedFetch('/api/items')
expect(loading.value).toBe(true)
await nextTick()
expect(mockFetch).toHaveBeenCalledWith('/api/items?page=1&size=20')
expect(items.value).toEqual([{ id: 1, name: 'Item 1' }])
expect(loading.value).toBe(false)
expect(error.value).toBe(null)
})
})
This approach isolates side effects and keeps tests fast. For components, I prefer rendering tests with @vue/test-utils and focusing on behavior rather than implementation details.
Performance considerations
Reactivity is powerful but not free. Some patterns can degrade performance in large apps:
- Avoid deep reactive objects when you only need a few properties. Use
shallowReforshallowReactivefor large data structures you mutate manually. - In lists, prefer keyed v-for and virtualization for long datasets.
- Minimize watchers with heavy side effects. Use
computedfor derived data when possible. - Track dependencies carefully:
watchEffectcan be convenient but may rerun too often if it touches many reactive sources.
In practice, if you’re handling thousands of rows or frequent updates, combine shallowRef with manual updates. For example:
import { shallowRef } from 'vue'
const bigList = shallowRef([])
// Later, replace the entire list
bigList.value = await fetchBigList()
// If you need to update one item, prefer replacing the array or using a map
This pattern gives you control over when reactivity triggers, which can make a significant difference in high-frequency scenarios.
Strengths, weaknesses, and tradeoffs
Strengths:
- Flexible code organization, especially for complex components.
- Excellent TypeScript support and editor autocompletion.
- Encourages reuse via composables, reducing duplication.
- Works well alongside the Options API in mixed codebases.
Weaknesses:
- It’s easy to over-abstract into too many small composables.
watchEffectcan cause unexpected reruns if dependencies aren’t clear.- Refs can be verbose in templates when using
.valuein non-<script setup>contexts. - New developers may struggle with reactivity rules and lifecycle timing.
Tradeoffs:
- For simple components, Options API is faster to write and easier to read.
- Composition API adds some boilerplate in tiny components but pays off as complexity grows.
- TypeScript adoption increases setup but significantly improves maintainability.
When to skip:
- Very small, static components where the added separation doesn’t bring value.
- Projects where the team is highly effective with Options API and timing constraints are tight.
- If your team hasn’t adopted Vue 3 yet, consider the migration path before investing deeply.
Free learning resources
- Vue 3 Composition API documentation: https://vuejs.org/guide/extras/composition-api-faq.html - Concise, official overview.
- Vue School’s Composition API videos: https://vueschool.io/learning/vue-3-composition-api - Practical lessons and examples.
- Vue Test Utils guide: https://test-utils.vuejs.org/guide/ - How to test composables and components effectively.
- Vite documentation: https://vitejs.dev/guide/why.html - Why Vite matters for Vue development and hot module replacement.
- Typescript with Vue: https://vuejs.org/guide/typescript/composition-api.html - Typing refs and composables.
Summary and recommendations
The Composition API is a natural fit for teams building medium-to-large Vue applications. It offers better organization for complex components, stronger TypeScript integration, and a clean pattern for reusing business logic. It doesn’t replace the Options API for simple use cases, and it shouldn’t be used to create unnecessary layers of abstraction.
Who should use it:
- Developers building feature-rich components with multiple concerns.
- Teams adopting TypeScript or maintaining large codebases long-term.
- Engineers who value explicitness and maintainability in reactivity and side effects.
Who might skip:
- Small projects where the Options API is already sufficient and fast to write.
- Teams on Vue 2.x without a clear path to 2.7 or Vue 3, where the effort may outweigh the benefits.
- Anyone looking for “magic” reactivity without clear boundaries or cleanup.
From my experience, the biggest wins came from using the Composition API to group logic by feature and to isolate async flows with cancellation and error handling. The biggest mistakes came from premature abstraction and misusing watchEffect without understanding its dependency tracking. When used thoughtfully, the Composition API makes Vue development feel predictable, modular, and enjoyable.




