Vue Composables: A Practical Guide for Product Teams
Composables — functions that use Vue's Composition API to encapsulate and share stateful logic — are one of the most powerful features of Vue 3. They're also frequently misused: either too rarely (the same logic copy-pasted across components) or too eagerly (composables wrapping single lines of code).
Here's a practical guide to getting composables right in a product codebase.
What composables are for
The Composition API gives you reactive primitives: ref, computed, watch. Composables are how you group these primitives into reusable units of logic.
The rule: if the same reactive logic appears in two or more places, extract it into a composable.
Common candidates:
- Authentication state (current user, auth status)
- Form state (values, validation, submission)
- Data fetching with loading/error states
- Clipboard operations
- Local storage synchronisation
- Debounced search
- Scroll position tracking
The anatomy of a composable
A composable is just a function. By convention, composable names start with use.
// composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, initialValue: T) {
const storedValue = localStorage.getItem(key)
const state = ref<T>(storedValue ? JSON.parse(storedValue) : initialValue)
watch(state, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
})
return state
}
Usage:
<script setup>
const theme = useLocalStorage('theme', 'light')
// theme is a ref that automatically syncs with localStorage
</script>
This is the essence of the pattern: you've taken stateful logic that would otherwise be repeated, extracted it with a clear interface, and made it reusable.
A composable for data fetching
One of the most common patterns in product code is fetching data with loading and error states. Instead of repeating this in every component, extract it:
// composables/useFetch.ts
export function useRequest<T>(fetcher: () => Promise<T>) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await fetcher()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
return { data, loading, error, execute }
}
<script setup>
const { data: users, loading, error, execute: fetchUsers } = useRequest(() =>
$fetch('/api/users')
)
onMounted(fetchUsers)
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Failed to load users</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</template>
Every component that fetches data uses the same pattern, the same loading state shape, the same error handling.
Composables for forms
Forms are where composable patterns pay off most visibly. Validation logic is often complex, often repeated, and usually tightly coupled to the form component.
// composables/useForm.ts
export function useForm<T extends Record<string, any>>(
initialValues: T,
validate: (values: T) => Partial<Record<keyof T, string>>
) {
const values = reactive({ ...initialValues })
const errors = reactive({} as Partial<Record<keyof T, string>>)
const submitting = ref(false)
function validateForm() {
const newErrors = validate(values as T)
Object.assign(errors, newErrors)
return Object.keys(newErrors).length === 0
}
async function handleSubmit(onSubmit: (values: T) => Promise<void>) {
if (!validateForm()) return
submitting.value = true
try {
await onSubmit(values as T)
} finally {
submitting.value = false
}
}
return { values, errors, submitting, handleSubmit }
}
What composables shouldn't do
Don't make composables too small. A composable wrapping a single ref adds indirection without value.
// Pointless
function useCount() {
return ref(0)
}
// Just use: const count = ref(0)
Don't mix concerns. A composable should have a clear, single responsibility. useUserProfile that also handles payment status and notification preferences is doing too much.
Don't put composables in components. Composables live in a dedicated composables/ directory. They don't depend on any specific component's context; they're pure logic.
Auto-import with Nuxt
Nuxt 3 auto-imports composables from the composables/ directory. You don't need to import them explicitly in your components:
<script setup>
// No import needed — useLocalStorage is auto-imported from composables/
const theme = useLocalStorage('theme', 'light')
</script>
This is a significant developer experience improvement for large codebases. Your composables are always available, your imports are minimal, and the convention is clear.
Building a composable library
As a product codebase matures, the composables/ directory becomes a library of tested, reusable building blocks. Developers reach for existing composables rather than reinventing patterns.
A healthy composable library after 6-12 months of development might include:
useAuth— authentication state and methodsuseToast— toast notification systemuseConfirm— confirmation dialoguseClipboard— clipboard read/writeusePagination— paginated data fetchinguseDebounce— debounced reactive valuesuseIntersectionObserver— scroll-based triggers
Each of these replaces dozens of lines of repeated code across the product.
Building on Vue/Nuxt and want clean, maintainable code? Let's talk →