20 KiB
Vue Composables Best Practices Reference
Table of Contents
- What is a Composable?
- Inline vs External Composables
- File Naming and Structure
- Composable Anatomy
- Single Responsibility Principle
- Argument Passing
- Error Handling
- Separation of Concerns: No UI Logic
- Functional Core, Imperative Shell
- Consistent File Structure
- Composable Composition
- Return Values
- TypeScript Best Practices
- Common Patterns
- Testing Composables
- Common Mistakes
- Quick Checklist
What is a Composable?
Composables encapsulate reusable stateful logic using Vue's Composition API.
Inline vs External Composables
CRITICAL PRINCIPLE: Start with inline composables for component-specific logic. Only extract to external files when the logic is reused in multiple components.
The Decision Framework
// ✅ CORRECT: Component-specific logic stays INLINE
<script setup lang="ts">
// Inline composable - used only in this component
function useHiddenFolders() {
const showHidden = ref(localStorage.getItem('show-hidden') === 'true')
watch(showHidden, (value) => {
if (value) {
localStorage.setItem('show-hidden', 'true')
} else {
localStorage.removeItem('show-hidden')
}
})
return { showHidden }
}
// Use the inline composable
const { showHidden } = useHiddenFolders()
</script>
// ❌ WRONG: Extracting to external file when used in ONE component
// src/composables/useHiddenFolders.ts - only used in one place!
export function useHiddenFolders() { /* ... */ }
When to Use Each Approach
| Pattern | When to Use | Example |
|---|---|---|
| Inline composable | Logic specific to ONE component | Form validation, local UI state, component-specific data fetching |
| External composable | Logic reused in 2+ components | Authentication, global state, shared API calls |
Real-World Example: FolderManager Component
This example shows inline composables organizing component logic:
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useQuery, mutate } from 'vue-apollo'
// External composables (reusable across app)
import { useNetworkState } from '@/composables/useNetworkState'
// GraphQL queries
import FOLDERS_FAVORITE from '@/graphql/folder/favoriteFolders.gql'
import FOLDER_SET_FAVORITE from '@/graphql/folder/folderSetFavorite.gql'
// Network state (reusable)
const { networkState } = useNetworkState()
// Component-specific logic as inline composables
const { showHiddenFolders } = useHiddenFolders()
const { favoriteFolders, toggleFavorite } = useFavoriteFolders()
// Inline composable #1: Hidden folders management
function useHiddenFolders() {
const showHiddenFolders = ref(
localStorage.getItem('show-hidden-folders') === 'true'
)
watch(showHiddenFolders, (value) => {
if (value) {
localStorage.setItem('show-hidden-folders', 'true')
} else {
localStorage.removeItem('show-hidden-folders')
}
})
return { showHiddenFolders }
}
// Inline composable #2: Favorite folders management
function useFavoriteFolders() {
const favoriteFolders = useQuery(FOLDERS_FAVORITE, [])
async function toggleFavorite(folderPath: string) {
await mutate({
mutation: FOLDER_SET_FAVORITE,
variables: { path: folderPath }
})
}
return { favoriteFolders, toggleFavorite }
}
</script>
<template>
<div>
<input v-model="showHiddenFolders" type="checkbox" />
<ul>
<li v-for="folder in favoriteFolders" :key="folder.path">
{{ folder.name }}
<button @click="toggleFavorite(folder.path)">Toggle</button>
</li>
</ul>
</div>
</template>
Benefits of Inline Composables
Readability:
- Related logic grouped together
- Clear component structure at a glance
- No need to jump between files
Maintainability:
- Changes stay in one file
- No premature abstraction
- Easier to understand component behavior
Flexibility:
- Easy to refactor when needed
- Can access component-specific imports
- Simple to extract later if reused
When to Extract to External File
Extract an inline composable to src/composables/ when:
- Used in 2+ components - Actual reuse, not "might be reused"
- Shared business logic - Authentication, API patterns, etc.
- Testing isolation needed - Complex logic requiring dedicated tests
// After you use the SAME logic in a second component, extract it:
// src/composables/useHiddenFolders.ts
export function useHiddenFolders() {
const showHiddenFolders = ref(
localStorage.getItem('show-hidden-folders') === 'true'
)
watch(showHiddenFolders, (value) => {
if (value) {
localStorage.setItem('show-hidden-folders', 'true')
} else {
localStorage.removeItem('show-hidden-folders')
}
})
return { showHiddenFolders }
}
Common Anti-Patterns
// ❌ WRONG: Extracting too early
// Creating external composable used in only ONE component
// src/composables/useComponentSpecificThing.ts - only used once!
// ❌ WRONG: Not organizing component logic
// Flat <script setup> with mixed concerns
<script setup lang="ts">
const showHidden = ref(localStorage.getItem('show-hidden') === 'true')
watch(showHidden, (value) => { /* ... */ })
const favoriteFolders = useQuery(FOLDERS_FAVORITE, [])
async function toggleFavorite() { /* ... */ }
// ...hundreds of lines of mixed logic
</script>
// ✅ CORRECT: Inline composables organize component
<script setup lang="ts">
const { showHidden } = useHiddenFolders()
const { favoriteFolders, toggleFavorite } = useFavoriteFolders()
function useHiddenFolders() { /* grouped logic */ }
function useFavoriteFolders() { /* grouped logic */ }
</script>
Quick Decision Checklist
- Is this logic used in only ONE component? → Inline composable
- Does it organize complex component logic? → Inline composable
- Is it actually reused in 2+ components NOW? → External composable
- Is it shared business logic (auth, API)? → External composable
- Might it be reused someday? → Keep inline until that day comes
Remember: Premature extraction is premature optimization. Start inline, extract when you have proof of reuse.
File Naming and Structure
Note: This section applies to external composables in src/composables/. Inline composables (defined within components) don't need separate files.
Naming Convention
✅ CORRECT:
src/composables/
useCounter.ts // External - reused in multiple components
useUserData.ts // External - shared business logic
useApiRequest.ts // External - used across app
❌ WRONG:
src/composables/
counter.ts // Missing 'use' prefix
APIrequest.ts // Wrong casing
user-data.ts // Wrong casing
useComponentSpecificThing.ts // Should be inline in component!
Rules for External Composables:
- ALWAYS prefix with
use - ALWAYS use PascalCase after
use:useUserDatanotuseuserdata - Place in
src/composables/directory - One composable per file
- Only extract when reused in 2+ components
Function Naming
// ✅ CORRECT: Descriptive names
export function useUserData() {}
export function useApiRequest() {}
export function useLocalStorage() {}
// ❌ WRONG: Too generic
export function useData() {}
export function useRequest() {}
export function useStorage() {}
Composable Anatomy
A well-structured composable has three parts:
export function useUserData(userId: string) {
// 1. PRIMARY STATE - Main reactive data
const user = ref<User | null>(null)
// 2. STATE METADATA - Supporting state (loading, error, etc.)
const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle')
const error = ref<Error | null>(null)
// 3. METHODS - Functions that update state
const fetchUser = async () => {
status.value = 'loading'
error.value = null
try {
const response = await axios.get(`/api/users/${userId}`)
user.value = response.data
status.value = 'success'
} catch (e) {
error.value = e as Error
status.value = 'error'
}
}
return { user, status, error, fetchUser }
}
Structure Order:
- Primary State (the main data)
- State Metadata (loading, error, etc.)
- Methods (functions that manipulate state)
Single Responsibility Principle
Each composable should do ONE thing well.
// ✅ CORRECT: Single responsibility
export function useCounter() {
const count = ref(0)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = 0
return { count, increment, decrement, reset }
}
// ❌ WRONG: Multiple responsibilities
export function useUserAndCounter(userId: string) {
// User management
const user = ref<User | null>(null)
const fetchUser = async () => { /* ... */ }
// Counter logic (unrelated!)
const count = ref(0)
const increment = () => count.value++
return { user, fetchUser, count, increment }
}
Fix: Create two composables: useUser and useCounter.
Argument Passing
Four or More Parameters: Use Object
// ✅ CORRECT: Object for 4+ parameters
useUserData({
id: 1,
fetchOnMount: true,
token: 'abc',
locale: 'en'
})
// ✅ CORRECT: Individual args for 3 or fewer
useCounter(initialValue, autoIncrement, storageKey)
// ❌ WRONG: Many individual args
useUserData(1, true, 'abc', 'en', false, null)
With TypeScript:
interface UseUserDataOptions {
id: number
fetchOnMount?: boolean
token?: string
locale?: string
}
export function useUserData(options: UseUserDataOptions) {
const { id, fetchOnMount = false, token, locale = 'en' } = options
// ...
}
Error Handling
ALWAYS expose error state, NEVER just console.error.
// ✅ CORRECT: Expose errors
export function useUserData(userId: string) {
const user = ref<User | null>(null)
const error = ref<Error | null>(null)
const fetchUser = async () => {
try {
const response = await axios.get(`/api/users/${userId}`)
user.value = response.data
} catch (e) {
error.value = e as Error // Expose to caller
}
}
return { user, error, fetchUser }
}
// ❌ WRONG: Swallow errors
export function useUserDataBad(userId: string) {
const user = ref<User | null>(null)
const fetchUser = async () => {
try {
const response = await axios.get(`/api/users/${userId}`)
user.value = response.data
} catch (e) {
console.error('Error:', e) // Component can't handle error
}
}
return { user, fetchUser } // No error exposed
}
Separation of Concerns: No UI Logic in Composables
Composables handle STATE and BUSINESS LOGIC only. UI concerns (toasts, alerts, modals) belong in components.
// ✅ CORRECT: Business logic only
export function useUserData(userId: string) {
const user = ref<User | null>(null)
const error = ref<Error | null>(null)
const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle')
const fetchUser = async () => {
status.value = 'loading'
try {
const response = await axios.get(`/api/users/${userId}`)
user.value = response.data
status.value = 'success'
} catch (e) {
error.value = e as Error
status.value = 'error'
}
}
return { user, error, status, fetchUser }
}
// In component - UI layer handles presentation
const { user, error, status, fetchUser } = useUserData(userId)
watch(status, (s) => {
if (s === 'success') showToast('Success!') // UI in component
if (s === 'error') showToast(`Error: ${error.value?.message}`)
})
// ❌ WRONG: UI logic in composable
export function useUserDataBad(userId: string) {
const user = ref<User | null>(null)
const fetchUser = async () => {
try {
const response = await axios.get(`/api/users/${userId}`)
user.value = response.data
} catch (e) {
showToast('Failed to load user') // UI logic in composable
}
}
return { user, fetchUser }
}
Why separation matters:
- Composable is testable without mocking UI
- Different components can use different UI (toast, modal, inline error)
- Composable works in non-UI contexts (background tasks, SSR)
- Follows separation of concerns
Functional Core, Imperative Shell (Optional Pattern)
Separate pure functions (core logic) from side effects (shell).
// ✅ CORRECT: Functional core, imperative shell
// Functional Core (pure, testable)
const calculate = (a: number, b: number) => a + b
const multiply = (a: number, b: number) => a * b
// Imperative Shell (Vue reactivity, side effects)
export function useCalculator() {
const result = ref(0)
const add = (a: number, b: number) => {
result.value = calculate(a, b) // Use pure function
}
const times = (a: number, b: number) => {
result.value = multiply(a, b)
}
return { result, add, times }
}
// ❌ WRONG: Mixed concerns
export function useCalculatorBad() {
const result = ref(0)
const add = (a: number, b: number) => {
console.log('Adding:', a, b) // Side effect mixed with logic
result.value = a + b
}
return { result, add }
}
Benefits:
- Pure functions are easy to test
- Side effects isolated to shell
- Better separation of concerns
Consistent File Structure
Maintain consistent ordering within composable files:
import { ref, computed, watch, onMounted } from 'vue'
export function useExample() {
// 1. INITIALIZING
// Setup, imports, router, other composables
const router = useRouter()
const { user } = useAuth()
// 2. REFS
const count = ref(0)
const data = ref<Data | null>(null)
// 3. COMPUTED
const isEven = computed(() => count.value % 2 === 0)
const hasData = computed(() => data.value !== null)
// 4. METHODS
const increment = () => count.value++
const fetchData = async () => { /* ... */ }
// 5. LIFECYCLE HOOKS
onMounted(() => {
fetchData()
})
// 6. WATCH
watch(count, (newCount) => {
console.log('Count changed:', newCount)
})
return { count, isEven, increment, data, fetchData }
}
Recommended Order:
- Initializing (setup, other composables)
- Refs (reactive state)
- Computed (derived state)
- Methods (functions)
- Lifecycle Hooks (onMounted, onUnmounted, etc.)
- Watch (watchers)
Composable Composition
Composables can use other composables:
// ✅ CORRECT: Composing composables
export function useAuthenticatedUser(userId: string) {
const { token, isAuthenticated } = useAuth()
const { user, error, fetchUser } = useUserData(userId)
// Only fetch if authenticated
watchEffect(() => {
if (isAuthenticated.value && token.value) {
fetchUser()
}
})
return { user, error, isAuthenticated }
}
Rules for composition:
- Call composables at the top of your composable function
- Don't call composables conditionally
- Compose related functionality (auth + user data)
- Maintain single responsibility even when composing
Return Values
Return an object with named properties for clarity:
// ✅ CORRECT: Named object return
export function useCounter() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
// Usage
const { count, increment } = useCounter()
// ❌ WRONG: Array return (unclear)
export function useCounterBad() {
const count = ref(0)
const increment = () => count.value++
return [count, increment] // Which is which?
}
TypeScript Best Practices
// ✅ CORRECT: Proper typing
interface User {
id: number
name: string
email: string
}
interface UseUserDataReturn {
user: Ref<User | null>
error: Ref<Error | null>
status: Ref<'idle' | 'loading' | 'success' | 'error'>
fetchUser: () => Promise<void>
}
export function useUserData(userId: string): UseUserDataReturn {
const user = ref<User | null>(null)
const error = ref<Error | null>(null)
const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle')
const fetchUser = async () => {
status.value = 'loading'
try {
const response = await axios.get<User>(`/api/users/${userId}`)
user.value = response.data
status.value = 'success'
} catch (e) {
error.value = e as Error
status.value = 'error'
}
}
return { user, error, status, fetchUser }
}
Common Patterns
Loading State Pattern
export function useAsyncData<T>(fetcher: () => Promise<T>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const isLoading = ref(false)
const execute = async () => {
isLoading.value = true
error.value = null
try {
data.value = await fetcher()
} catch (e) {
error.value = e as Error
} finally {
isLoading.value = false
}
}
return { data, error, isLoading, execute }
}
LocalStorage Sync Pattern
export function useLocalStorage<T>(key: string, defaultValue: T) {
const data = ref<T>(defaultValue)
// Load from localStorage on init
const stored = localStorage.getItem(key)
if (stored) {
try {
data.value = JSON.parse(stored)
} catch (e) {
console.warn('Failed to parse stored value')
}
}
// Sync to localStorage on change
watch(data, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
return data
}
Debounced Input Pattern
export function useDebouncedRef<T>(value: T, delay = 300) {
const immediate = ref(value)
const debounced = ref(value)
watch(immediate, (newValue) => {
setTimeout(() => {
debounced.value = newValue
}, delay)
})
return { immediate, debounced }
}
Testing Composables
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('increments count', () => {
const { count, increment } = useCounter()
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
})
it('decrements count', () => {
const { count, decrement } = useCounter()
count.value = 5
decrement()
expect(count.value).toBe(4)
})
})
For async composables, use Vue Test Utils:
import { mount } from '@vue/test-utils'
it('fetches user data', async () => {
const wrapper = mount({
setup() {
return useUserData('123')
},
template: '<div></div>'
})
const { fetchUser, user, status } = wrapper.vm
await fetchUser()
expect(status.value).toBe('success')
expect(user.value).toBeDefined()
})
Common Mistakes
| Mistake | Fix |
|---|---|
| Extracting to external file too early | Start inline, extract when reused in 2+ components |
| External composable used in only ONE component | Move to inline composable in that component |
Missing use prefix |
Always prefix: useCounter not counter |
| UI logic in composable | Move toasts/alerts to component |
| Multiple responsibilities | Split into focused composables |
| Not exposing errors | Always return error state |
| Too many parameters | Use object for 4+ params |
| Conditional composable calls | Call composables at top level only |
| Array returns | Use named object returns |
| Missing TypeScript types | Type all refs and return values |
Quick Checklist
Before Creating External Composable:
- Is this logic used in 2+ components? (If not, keep inline)
- Is this shared business logic that should be centralized?
For All Composables (Inline and External):
- Single responsibility (one thing well)
- Exposes error state (not just console.error)
- No UI logic (toasts, alerts, modals)
- Consistent internal structure (refs → computed → methods → lifecycle → watch)
- Named object return (not array)
- Full TypeScript types
- 4+ params use object, 3 or fewer use individual args
For External Composables Only:
- File named with
useprefix and PascalCase - Located in
src/composables/directory - Actually reused in multiple components (not "might be reused")