Vue 3 Composition API: A Practical Guide With Real Examples
Move beyond the docs with a practical guide to Vue 3 Composition API patterns — reactive state, composables, lifecycle hooks, and real production examples.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
The Composition API is the biggest conceptual shift Vue has ever made, and a lot of developers are still not using it effectively. I see codebases that adopted <script setup> syntactically but kept the same organizational patterns from the Options API — everything in a flat list, no composables, logic that belongs together scattered across the file.
This guide is about using the Composition API the way it was designed to be used. Not just the syntax, but the patterns that make it genuinely better than what came before.
Why the Composition API Exists
The Options API organized code by option type: data, methods, computed, watch. The problem with that structure is that a single logical concern — say, managing a user's authentication state — gets split across multiple sections. The data lives in data, the methods live in methods, the watchers live in watch. Reading the code for one feature requires jumping between sections.
The Composition API organizes code by logical concern instead. Everything related to authentication lives together. Everything related to pagination lives together. When you need to understand or modify a feature, you read a contiguous block of code instead of playing connect-the-dots across a file.
This sounds minor until you work on a component with eight logical concerns. Then it matters a lot.
The Basics With Script Setup
The <script setup> syntax is the right default for all new components. It is more concise and has better TypeScript inference than the alternative forms:
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
interface User {
id: number
name: string
email: string
}
// Reactive state
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Computed property
const displayName = computed(() => user.value?.name ?? 'Guest')
// Watch for changes
watch(user, (newUser) => {
if (newUser) {
document.title = `Profile — ${newUser.name}`
}
})
// Lifecycle hook
onMounted(async () => {
loading.value = true
try {
const response = await fetch('/api/user/me')
user.value = await response.json()
} catch (e) {
error.value = 'Failed to load user'
} finally {
loading.value = false
}
})
</script>
Everything declared at the top level of <script setup> is automatically available in the template. No explicit return statement needed. The TypeScript integration is clean — interfaces defined here flow into the template without additional configuration.
Reactive vs Ref
One source of confusion for developers new to the Composition API is when to use ref versus reactive.
My rule: use ref for everything. It is consistent, it is predictable, and you always know that the underlying value is at .value. With reactive, you lose reactivity if you destructure the object, which causes subtle bugs.
// This loses reactivity — don't do this with reactive()
const { count } = reactive({ count: 0 })
count++ // NOT reactive
// ref is safe to destructure with toRefs
const state = reactive({ count: 0 })
const { count } = toRefs(state)
count.value++ // reactive
Using ref consistently eliminates this entire class of bugs. The .value access is a small price to pay for predictability.
Composables: The Real Power
The Composition API's killer feature is composables — functions that encapsulate reactive state and logic. This is where the Options API simply cannot compete.
Here is a real composable I use for data fetching with loading, error, and abort control:
// composables/useFetch.ts
export function useApiFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
let controller: AbortController | null = null
async function execute() {
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = null
try {
const response = await fetch(toValue(url), {
signal: controller.signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (e) {
if (e instanceof Error && e.name !== 'AbortError') {
error.value = e.message
}
} finally {
loading.value = false
}
}
// Re-execute when URL changes
watchEffect(execute)
onUnmounted(() => controller?.abort())
return { data, loading, error, execute }
}
Now any component that needs to fetch data gets consistent loading states, error handling, and automatic cleanup — without repeating that logic:
<script setup lang="ts">
const { data: posts, loading, error } = useApiFetch<Post[]>('/api/posts')
</script>
<template>
<div>
<LoadingSpinner v-if="loading" />
<ErrorMessage v-else-if="error" :message="error" />
<PostList v-else :posts="posts ?? []" />
</div>
</template>
Composables That Share State
Composables can also share state across components. When you call useState (Vue's equivalent is calling ref outside a component, or using Pinia), all components sharing that state stay in sync:
// composables/useTheme.ts
const theme = ref<'light' | 'dark'>('light')
export function useTheme() {
function toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
document.documentElement.classList.toggle('dark', theme.value === 'dark')
}
return { theme: readonly(theme), toggle }
}
Because theme is defined outside the composable function, it is a singleton — every component calling useTheme() shares the same reactive reference. This is a simple alternative to a full state management solution for straightforward cases.
Provide and Inject
For passing data deeply through a component tree without prop drilling, provide and inject are the right tool:
// Parent component
const userId = ref(42)
provide('userId', readonly(userId))
// Deep child component
const userId = inject<Ref<number>>('userId')
I type the injection keys to avoid runtime errors:
// injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
export const userIdKey: InjectionKey<Ref<number>> = Symbol('userId')
// Provider
provide(userIdKey, readonly(userId))
// Consumer
const userId = inject(userIdKey) // fully typed
This pattern is excellent for things like form context (passing form state to nested form fields) or theme context (passing the current theme to all nested components).
Watchers Done Right
Three watcher types exist and each has its use case:
watch runs when a specific source changes. Use this when you need to react to a specific value changing.
watchEffect runs immediately and re-runs whenever any reactive dependency it accesses changes. Use this for side effects that depend on reactive state in ways that are hard to enumerate statically.
watchPostEffect is like watchEffect but runs after the DOM is updated. Use this when your effect needs access to the updated DOM.
// Watch a specific value
watch(userId, async (newId) => {
user.value = await fetchUser(newId)
})
// Watch anything the function touches
watchEffect(async () => {
// Automatically re-runs when userId changes
// because it accesses userId.value inside
user.value = await fetchUser(userId.value)
})
A common mistake is using watch with a callback that accesses many reactive values, then being surprised when it does not re-run when one of those values changes. watchEffect is often the right choice when your effect has multiple dependencies.
TypeScript Integration
The Composition API was designed with TypeScript in mind, and the integration is excellent. Props definitions with TypeScript interfaces give you full type safety in templates:
interface Props {
user: User
onSave: (user: User) => void
}
const props = defineProps<Props>()
const emit = defineEmits<{
save: [user: User]
cancel: []
}>()
No runtime validators needed when you are using TypeScript — the types are enforced at compile time.
Migrating From Options API
If you are maintaining Vue 2 or early Vue 3 code still using the Options API, do not rewrite everything at once. The Composition API can coexist with the Options API in a codebase. Start by extracting shared logic into composables. Then gradually convert components to <script setup> as you touch them for feature work. Complete migrations under deadline pressure introduce bugs — do it incrementally.
The Composition API is not just a different syntax for the same patterns. It enables genuinely better code organization, better TypeScript support, and better logic reuse. Once you build a few real composables, you will not want to go back.
If you are working through a Vue 3 migration or designing the architecture for a new application, I am happy to help you think through the structure. Book a call at calendly.com/jamesrossjr.