Why the Composition API Changes Everything
Vue.js 3 introduced the Composition API as an alternative to the Options API that dominated Vue 2 development. While the Options API organizes code by option type — data, methods, computed, watch — the Composition API organizes code by logical concern. This distinction matters far more than it sounds on paper. In a complex component with multiple features, the Options API scatters related logic across different sections of the file. The Composition API lets you group related state, computed values, and functions together, then extract them into reusable composable functions.
This guide walks through the Composition API from the ground up with practical examples you can use in production. We cover reactive state, composable functions, lifecycle hooks, provide/inject patterns, and a complete reactive store implementation — everything you need to build scalable Vue 3 applications. If you are comparing Vue against other frameworks before committing, our React vs Vue vs Svelte comparison covers the architectural differences in detail.
The Composition API is not a replacement for the Options API. Both coexist in Vue 3, and the Options API remains a valid choice for simpler components. But once your components grow beyond 200 lines or you need to share logic across components, the Composition API becomes the clearly superior approach.
Setting Up a Vue 3 Project with the Composition API
The fastest way to start a Vue 3 project in 2025 is with create-vue, the official scaffolding tool built on Vite. It generates a project configured with the Composition API, TypeScript support, and modern tooling out of the box.
npm create vue@latest my-project
cd my-project
npm install
npm run dev
The scaffolding wizard lets you opt into TypeScript, JSX, Vue Router, Pinia, Vitest, and ESLint. For production applications, enabling TypeScript is strongly recommended — the Composition API was designed with TypeScript in mind, and type inference works significantly better than it did with the Options API. Our guide on TypeScript for JavaScript developers covers the transition if you have not made the switch yet.
Every Composition API component uses the <script setup> syntax, which is the recommended and most concise way to write components. Here is the minimal structure:
<script setup lang="ts">
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">
Count: {{ count }} (doubled: {{ doubled }})
</button>
</template>
Variables and functions declared in <script setup> are automatically available in the template. There is no need for a return statement, no this keyword, and no option registration. This simplicity is one of the Composition API’s biggest advantages.
Reactive State: ref vs reactive
Vue 3 provides two primary functions for creating reactive state: ref and reactive. Understanding when to use each is the first practical decision you face.
ref wraps a value in a reactive container. It works with any value type — primitives, objects, arrays. You access and mutate the value through the .value property in JavaScript, but in templates, Vue automatically unwraps it.
import { ref } from 'vue'
const name = ref('Vue Developer')
const skills = ref(['JavaScript', 'TypeScript', 'Vue'])
// In script: access via .value
name.value = 'Senior Vue Developer'
skills.value.push('Nuxt')
// In template: auto-unwrapped, no .value needed
// {{ name }} renders "Senior Vue Developer"
reactive creates a deeply reactive proxy of an object. Unlike ref, there is no .value wrapper — you access properties directly. However, reactive only works with objects, maps, sets, and arrays. It cannot wrap primitives.
import { reactive } from 'vue'
const state = reactive({
user: { name: 'Vue Developer', role: 'frontend' },
isLoggedIn: false,
preferences: { theme: 'dark', language: 'en' }
})
// Direct property access — no .value
state.isLoggedIn = true
state.user.name = 'Senior Vue Developer'
The Vue team recommends ref as the default choice. The reasoning is practical: ref works consistently with all value types, it survives destructuring (unlike reactive), and the .value convention makes it explicit when you are working with reactive state. Many teams adopt a simple rule — use ref for everything unless you have a specific reason to use reactive.
Computed Properties and Watchers
Computed properties in the Composition API are created with the computed function. They automatically track their reactive dependencies and only recalculate when those dependencies change.
import { ref, computed } from 'vue'
const items = ref([
{ name: 'Task A', completed: false },
{ name: 'Task B', completed: true },
{ name: 'Task C', completed: false }
])
const completedCount = computed(() =>
items.value.filter(item => item.completed).length
)
const pendingItems = computed(() =>
items.value.filter(item => !item.completed)
)
Watchers let you run side effects when reactive state changes. The watch function watches specific sources, while watchEffect automatically tracks any reactive dependency used inside it.
import { ref, watch, watchEffect } from 'vue'
const searchQuery = ref('')
const results = ref([])
// watch — explicit source, gives old and new values
watch(searchQuery, async (newQuery, oldQuery) => {
if (newQuery.length > 2) {
results.value = await fetchResults(newQuery)
}
}, { debounce: 300 })
// watchEffect — auto-tracks dependencies
watchEffect(() => {
console.log(`Search: ${searchQuery.value}, Results: ${results.value.length}`)
})
Building Composable Functions
Composable functions are the Composition API’s answer to code reuse. A composable is simply a function that uses Vue’s reactivity system and returns reactive state, computed values, or methods. It replaces mixins, which were Vue 2’s primary reuse mechanism but suffered from naming collisions, implicit dependencies, and opacity.
The convention is to name composables with a use prefix — useFetch, useAuth, useLocalStorage. This mirrors React’s hook naming convention and makes composable usage instantly recognizable in code.
Here is a complete, production-ready composable for data fetching with loading states, error handling, caching, and automatic retry:
// composables/useFetch.ts
import { ref, shallowRef, computed, watchEffect, toValue } from 'vue'
import type { Ref, MaybeRefOrGetter } from 'vue'
interface UseFetchOptions<T> {
immediate?: boolean
defaultValue?: T
retryCount?: number
retryDelay?: number
cache?: boolean
transform?: (data: unknown) => T
}
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
isLoading: Ref<boolean>
isFinished: Ref<boolean>
hasError: Ref<boolean>
execute: () => Promise<void>
refresh: () => Promise<void>
}
const cache = new Map<string, { data: unknown; timestamp: number }>()
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
export function useFetch<T = unknown>(
url: MaybeRefOrGetter<string>,
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const {
immediate = true,
defaultValue = null,
retryCount = 0,
retryDelay = 1000,
cache: useCache = false,
transform = (d) => d as T
} = options
const data = shallowRef<T | null>(defaultValue)
const error = ref<Error | null>(null)
const isLoading = ref(false)
const isFinished = ref(false)
const hasError = computed(() => error.value !== null)
async function fetchWithRetry(
targetUrl: string,
retriesLeft: number
): Promise<T> {
try {
const response = await fetch(targetUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const json = await response.json()
return transform(json)
} catch (err) {
if (retriesLeft > 0) {
await new Promise((r) => setTimeout(r, retryDelay))
return fetchWithRetry(targetUrl, retriesLeft - 1)
}
throw err
}
}
async function execute(): Promise<void> {
const targetUrl = toValue(url)
if (useCache) {
const cached = cache.get(targetUrl)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
data.value = cached.data as T
isFinished.value = true
return
}
}
error.value = null
isLoading.value = true
isFinished.value = false
try {
const result = await fetchWithRetry(targetUrl, retryCount)
data.value = result
if (useCache) {
cache.set(targetUrl, { data: result, timestamp: Date.now() })
}
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err))
} finally {
isLoading.value = false
isFinished.value = true
}
}
if (immediate) {
watchEffect(() => {
toValue(url) // track reactivity
execute()
})
}
return {
data,
error,
isLoading,
isFinished,
hasError,
execute,
refresh: execute
}
}
This composable demonstrates several important patterns. It accepts a MaybeRefOrGetter for the URL, meaning it works with static strings, refs, and computed values — automatically re-fetching when a reactive URL changes. It uses shallowRef for the data to avoid deep reactivity overhead on large response objects. The cache prevents redundant network requests during navigation. Using it in a component is straightforward:
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFetch } from '@/composables/useFetch'
const userId = ref(1)
const apiUrl = computed(() => `https://api.example.com/users/${userId.value}`)
const { data: user, isLoading, hasError, error, refresh } = useFetch(apiUrl, {
cache: true,
retryCount: 2,
transform: (raw) => raw.user
})
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else-if="hasError">Error: {{ error?.message }}</div>
<div v-else-if="user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
<button @click="refresh">Refresh</button>
<button @click="userId++">Next User</button>
</div>
</template>
Composables compose naturally. A useUserProfile composable can internally use useFetch, useLocalStorage, and useDebounce — building complex behavior from simple, tested building blocks. This compositional pattern scales in ways that mixins never could.
Lifecycle Hooks in the Composition API
The Composition API provides lifecycle hooks as importable functions. Each hook accepts a callback that runs at the corresponding lifecycle stage. The key difference from the Options API is that you can call lifecycle hooks multiple times and from within composables, not just at the component level.
import { onMounted, onUnmounted, onUpdated, ref } from 'vue'
const windowWidth = ref(window.innerWidth)
function handleResize() {
windowWidth.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', handleResize)
console.log('Component mounted, DOM is available')
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
console.log('Cleanup complete')
})
The most commonly used lifecycle hooks are onMounted (DOM access, third-party library initialization), onUnmounted (cleanup, event listener removal), and onUpdated (post-DOM-update logic). The onBeforeMount and onBeforeUpdate hooks exist but are rarely needed in practice.
A critical advantage of Composition API lifecycle hooks is their composability. A composable like useEventListener can register and clean up event listeners internally without the consuming component needing to manage lifecycle at all:
// composables/useEventListener.ts
import { onMounted, onUnmounted } from 'vue'
export function useEventListener(
target: EventTarget,
event: string,
handler: EventListener
) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler))
}
Building a Reactive Store with the Composition API
While Pinia is the official state management library for Vue 3, the Composition API is powerful enough to build lightweight reactive stores for many applications without an external dependency. This approach works well for small to medium applications or for feature-specific state that does not need the full Pinia infrastructure.
Here is a complete reactive store pattern for managing application-wide notification state, with TypeScript types, actions, getters, and persistence:
// stores/useNotificationStore.ts
import { reactive, computed, readonly, toRefs } from 'vue'
type NotificationType = 'success' | 'error' | 'warning' | 'info'
interface Notification {
id: string
type: NotificationType
title: string
message: string
createdAt: number
read: boolean
autoDismiss: boolean
}
interface NotificationState {
items: Notification[]
maxItems: number
defaultDuration: number
}
const state = reactive<NotificationState>({
items: [],
maxItems: 50,
defaultDuration: 5000
})
// --- Getters (computed) ---
const unreadCount = computed(() =>
state.items.filter((n) => !n.read).length
)
const latestNotifications = computed(() =>
[...state.items]
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 10)
)
const groupedByType = computed(() => {
const groups: Record<NotificationType, Notification[]> = {
success: [], error: [], warning: [], info: []
}
state.items.forEach((n) => groups[n.type].push(n))
return groups
})
const hasUnread = computed(() => unreadCount.value > 0)
// --- Actions ---
function generateId(): string {
return `notif_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
function addNotification(
type: NotificationType,
title: string,
message: string,
options: { autoDismiss?: boolean; duration?: number } = {}
): string {
const { autoDismiss = true, duration = state.defaultDuration } = options
const id = generateId()
const notification: Notification = {
id,
type,
title,
message,
createdAt: Date.now(),
read: false,
autoDismiss
}
state.items.unshift(notification)
// Enforce max items limit
if (state.items.length > state.maxItems) {
state.items = state.items.slice(0, state.maxItems)
}
// Auto-dismiss after duration
if (autoDismiss) {
setTimeout(() => removeNotification(id), duration)
}
return id
}
function removeNotification(id: string): void {
const index = state.items.findIndex((n) => n.id === id)
if (index > -1) {
state.items.splice(index, 1)
}
}
function markAsRead(id: string): void {
const notification = state.items.find((n) => n.id === id)
if (notification) {
notification.read = true
}
}
function markAllAsRead(): void {
state.items.forEach((n) => { n.read = true })
}
function clearAll(): void {
state.items = []
}
// Convenience methods
const notify = {
success: (title: string, msg: string) =>
addNotification('success', title, msg),
error: (title: string, msg: string) =>
addNotification('error', title, msg, { autoDismiss: false }),
warning: (title: string, msg: string) =>
addNotification('warning', title, msg),
info: (title: string, msg: string) =>
addNotification('info', title, msg)
}
// --- Public API ---
export function useNotificationStore() {
return {
// State (readonly to prevent external mutation)
...toRefs(readonly(state)),
// Getters
unreadCount,
latestNotifications,
groupedByType,
hasUnread,
// Actions
addNotification,
removeNotification,
markAsRead,
markAllAsRead,
clearAll,
notify
}
}
This store pattern has several characteristics worth studying. The state is declared outside the composable function — this makes it a singleton, shared across all components that call useNotificationStore(). State is exposed through readonly and toRefs, preventing direct external mutation while allowing template destructuring. Actions are the only way to modify state, establishing a predictable data flow that mirrors Pinia and Vuex conventions.
Using the store in components follows the same pattern as any composable:
<script setup lang="ts">
import { useNotificationStore } from '@/stores/useNotificationStore'
const { latestNotifications, unreadCount, hasUnread, notify, markAsRead, clearAll } =
useNotificationStore()
function handleSaveSuccess() {
notify.success('Saved', 'Your changes have been saved successfully.')
}
</script>
<template>
<div class="notification-panel">
<header>
Notifications
<span v-if="hasUnread" class="badge">{{ unreadCount }}</span>
<button @click="clearAll">Clear All</button>
</header>
<ul>
<li
v-for="notification in latestNotifications"
:key="notification.id"
:class="[notification.type, { unread: !notification.read }]"
@click="markAsRead(notification.id)"
>
<strong>{{ notification.title }}</strong>
<p>{{ notification.message }}</p>
</li>
</ul>
</div>
</template>
For applications that outgrow this pattern — particularly when you need devtools integration, plugin support, or server-side rendering compatibility — Pinia provides the same composable-style API with additional infrastructure. The migration from a custom store to Pinia is straightforward because both follow the same reactive composition patterns.
Provide and Inject: Dependency Injection in Vue 3
The provide and inject functions enable dependency injection across component hierarchies without prop drilling. A parent component provides a value, and any descendant — regardless of depth — can inject it. This is particularly useful for theme configuration, authentication state, and feature flags.
// Parent component
import { provide, ref } from 'vue'
const theme = ref('dark')
const toggleTheme = () => {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
provide('theme', { theme, toggleTheme })
// Any descendant component
import { inject } from 'vue'
const { theme, toggleTheme } = inject('theme')!
For type safety, use InjectionKey symbols to define typed injection keys. This ensures that inject returns the correct type and catches mismatches at compile time:
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface ThemeContext {
theme: Ref<'dark' | 'light'>
toggleTheme: () => void
}
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
Using typed injection keys with provide/inject is one of many patterns that work better with TypeScript. The Composition API’s design aligns naturally with static type analysis — a deliberate design decision by the Vue core team. Building a coherent design system with consistent UI patterns becomes significantly easier when your injection contracts are typed.
Performance Optimization Patterns
The Composition API provides fine-grained control over reactivity, which translates directly into performance optimization opportunities. Here are patterns that matter in production.
shallowRef for Large Data Sets
When working with large arrays or deeply nested objects that change as a whole (like API responses), use shallowRef instead of ref. It only tracks changes to the .value reference, not deep mutations, avoiding the overhead of deep reactive proxies.
import { shallowRef } from 'vue'
// For large datasets, shallowRef avoids deep proxy overhead
const tableData = shallowRef<TableRow[]>([])
// Replace entire array to trigger reactivity
async function loadData() {
const response = await fetch('/api/data')
tableData.value = await response.json() // triggers update
}
Computed with Side-Effect-Free Getters
Computed properties cache their results and only recalculate when dependencies change. Keep computed getters pure — no side effects, no mutations. This guarantees caching works correctly and avoids subtle bugs.
Lazy Watchers and Debouncing
Use watchEffect with flush: 'post' for DOM-dependent side effects, and debounce watchers that trigger network requests. The watch function accepts an options object where you can control timing and behavior. For comprehensive front-end performance strategies beyond Vue-specific optimizations, see our web performance optimization guide.
Migrating from Options API to Composition API
If you have an existing Vue 2 or Vue 3 Options API codebase, migrating to the Composition API can be done incrementally. Vue 3 supports both APIs in the same component, so you can migrate one component at a time or even use both APIs within a single component during transition.
The general migration pattern maps directly:
data()properties becomereforreactivedeclarationscomputedproperties becomecomputed()function callsmethodsbecome regular functionswatchoptions becomewatch()orwatchEffect()callsmounted,unmountedbecomeonMounted(),onUnmounted()- Mixins become composable functions
The most impactful migration is converting mixins to composables. Every mixin in your codebase represents shared logic that benefits from explicit imports, proper TypeScript typing, and clear dependency graphs. Start there for the highest return on migration effort.
Composition API and the Vue Ecosystem
The Composition API is not an isolated feature — it shapes the entire Vue ecosystem in 2025. Nuxt 3 uses composables as its primary API surface (useFetch, useAsyncData, useRoute). Pinia stores are defined using composable-style setup functions. VueUse, the most popular Vue utility library, provides over 200 composables covering everything from browser APIs to animation.
This convergence around the composable pattern means skills you build with the Composition API transfer directly to Nuxt, Pinia, VueUse, and community libraries. It is the unifying abstraction of the Vue 3 ecosystem. For a broader perspective on how Vue’s meta-framework compares to alternatives, our Next.js vs Nuxt vs SvelteKit comparison covers the full-stack picture.
If you are building Vue applications as part of a larger web project, tools like Taskee can help manage your development workflow and keep component migration tasks organized across the team. For agencies handling multiple client projects with different framework choices, Toimi provides project management capabilities that scale across tech stacks.
Understanding modern JavaScript features is essential for working effectively with the Composition API. Destructuring, arrow functions, template literals, and modules are used extensively. If any of these feel unfamiliar, our ES6 features guide provides a thorough refresher.
Testing Composables
Composable functions are plain JavaScript functions that use Vue’s reactivity system. This makes them straightforward to test with Vitest or any other test runner — no component rendering required for most tests.
// useFetch.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFetch } from './useFetch'
import { nextTick } from 'vue'
describe('useFetch', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('fetches data and updates state', async () => {
const mockData = { id: 1, name: 'Test' }
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockData)
} as Response)
const { data, isLoading, isFinished } = useFetch('/api/test')
expect(isLoading.value).toBe(true)
await nextTick()
await vi.waitFor(() => expect(isFinished.value).toBe(true))
expect(data.value).toEqual(mockData)
expect(isLoading.value).toBe(false)
})
it('handles errors with retry', async () => {
vi.spyOn(global, 'fetch')
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true })
} as Response)
const { data, error } = useFetch('/api/test', {
retryCount: 2,
retryDelay: 10
})
await vi.waitFor(() => expect(data.value).toEqual({ success: true }))
expect(error.value).toBeNull()
})
})
For composables that rely on lifecycle hooks like onMounted, you need a component context. The @vue/test-utils library provides mount to create a wrapper component, or you can use Vitest’s withSetup helper pattern. The key insight is that most well-designed composables can be tested without a component — lifecycle-dependent logic should be isolated into specific composables rather than mixed into business logic. The best frameworks for building large-scale applications are the ones that make testing natural rather than an afterthought — a principle our best web frameworks 2026 guide evaluates as a key criterion.
Common Mistakes and How to Avoid Them
After working with the Composition API across dozens of production applications, certain mistakes appear repeatedly. Avoiding them from the start will save significant debugging time.
- Destructuring reactive objects. Destructuring a
reactiveobject loses reactivity. UsetoRefs(state)to create refs from each property, or usereffrom the start. - Forgetting .value in script. The
.valueaccess is required in JavaScript but not in templates. This inconsistency trips up beginners — TypeScript helps catch it at compile time. - Overusing watchEffect. Implicit dependency tracking is convenient but makes data flow harder to reason about. Prefer explicit
watchwhen you need to know exactly which state triggers which effect. - Creating non-singleton stores. If your store state is declared inside the composable function, each component gets its own copy. Declare state outside the function for shared stores.
- Ignoring cleanup. If you set up intervals, event listeners, or subscriptions in
onMounted, always clean them up inonUnmounted. Memory leaks in single-page applications accumulate with each navigation.
Frequently Asked Questions
Is the Composition API harder to learn than the Options API?
The Composition API has a steeper initial learning curve because it requires understanding JavaScript closures, reactivity primitives, and function composition. However, most developers find it easier to maintain and scale once they pass the initial learning phase. The Options API is simpler for small components, but the Composition API pays dividends as component complexity grows. For teams already comfortable with modern JavaScript and TypeScript, the transition is typically smooth.
Can I use the Options API and Composition API in the same project?
Yes. Vue 3 fully supports both APIs, and they can coexist in the same project without conflict. You can even use both within a single component by adding a setup() method alongside traditional options, though this is generally only useful during gradual migration. The Vue team has confirmed that the Options API will remain supported indefinitely — it is not deprecated.
Do I still need Vuex or Pinia if I use the Composition API?
Not necessarily. The Composition API enables lightweight reactive stores using reactive and ref outside of components, as demonstrated in this guide. For small to medium applications, custom composable stores may be all you need. However, Pinia adds devtools integration, server-side rendering support, plugin architecture, and hot module replacement for stores — features that become valuable in larger applications. Pinia is officially recommended by the Vue team for state management.
How does the Composition API affect performance compared to the Options API?
There is no meaningful runtime performance difference between the two APIs. Both compile to the same underlying Vue reactivity system. The Composition API can offer minor optimization advantages through shallowRef, more targeted watchers, and easier code splitting of composables. The real performance difference comes from architectural decisions — how you structure reactivity, avoid unnecessary computations, and manage component rendering — rather than API choice.
What is the difference between composables and React hooks?
While composables and hooks solve the same problem (logic reuse in components), they differ fundamentally in execution model. React hooks run on every render, which requires rules like the Rules of Hooks (no conditionals, consistent call order). Vue composables run once during setup, establishing reactive subscriptions that persist for the component’s lifetime. This means Vue composables have no ordering restrictions, can be called conditionally, and do not re-execute on re-renders. The result is fewer foot-guns and a more predictable mental model, though both approaches are productive in practice.