Vue 3 Composables 2026 — Design Patterns, VueUse v14, and a Reusable Architecture for Production

Posted on: 4/18/2026 6:14:32 AM

Table of contents

  1. 1. What is a Composable and Why Does It Matter?
  2. 2. Anatomy — The Standard Structure of a Composable
    1. Naming conventions
  3. 3. Core Patterns — Stateful vs Stateless vs Singleton
    1. Stateless Composable
    2. Singleton / Shared State
    3. When to use Singleton vs Pinia?
    4. 3.1 Factory Pattern — Composables that Create Composables
  4. 4. Advanced Patterns for Production
    1. 4.1 useAsyncState — The Standard Pattern for Async Operations
    2. 4.2 useEventListener — Auto-cleanup Side Effects
    3. 4.3 useDebouncedRef — A Custom Reactivity Primitive
    4. 4.4 Cancellable Requests with AbortController
  5. 5. VueUse v14 — The Production-Ready Composables Toolkit
  6. 6. Dependency Injection — provide/inject with Composables
    1. When to use provide/inject vs direct import?
  7. 7. Performance — Tuning Reactivity for Scale
    1. 7.1 shallowRef for Large Datasets
    2. 7.2 watchEffect Cleanup — Avoiding Memory Leaks
    3. 7.3 Computed Caching vs Method Calls
      1. Don't destructure reactive objects
  8. 8. Vue 3.5 — New APIs for Composables
    1. 8.1 useTemplateRef — Safer Template Refs
    2. 8.2 useId — SSR-safe Unique IDs
  9. 9. Testing Composables with Vitest
    1. 9.1 Direct Test — Reactivity-only Composables
    2. 9.2 withSetup Helper — Composables with Lifecycle
    3. 9.3 Testing with Dependency Injection
  10. 10. Composables Architecture in a Production Project
    1. 10.1 Functional Core, Imperative Shell
      1. Benefits of this pattern
    2. 10.2 Recommended Directory Structure
    3. 10.3 Composable Composition — Combining Small Composables into Domain Logic
  11. 11. Checklist — Best Practices Summary
  12. Conclusion
    1. References

The Composition API completely changed how developers write Vue. But its real power doesn't lie in ref() or computed() — it lies in composables, the reusable functions that package logic, state, and side-effects into independent modules. This article takes a deep look at design patterns, architecture, and best practices for Vue 3 composables in production 2026, from the basic structure to advanced patterns such as factory, singleton, dependency injection, and testing.

VueUse v14300+ ready-to-use composables
Vue 3.5+New useTemplateRef & useId APIs
70%Integration tests — 2026 testing strategy
VitestThe standard testing framework for composables

1. What is a Composable and Why Does It Matter?

A composable is a function prefixed with use that leverages the Composition API to encapsulate and reuse stateful logic. Unlike a plain utility function, a composable can contain reactive state, computed properties, watchers, and lifecycle hooks.

graph LR
    A["Component A"] -->|"useAuth()"| C["Composable"]
    B["Component B"] -->|"useAuth()"| C
    D["Component C"] -->|"useAuth()"| C
    C -->|"ref, computed"| E["Reactive State"]
    C -->|"onMounted, onUnmounted"| F["Lifecycle"]
    C -->|"watch, watchEffect"| G["Side Effects"]
    style C fill:#e94560,stroke:#fff,color:#fff
    style E fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style F fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style G fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style A fill:#2c3e50,stroke:#fff,color:#fff
    style B fill:#2c3e50,stroke:#fff,color:#fff
    style D fill:#2c3e50,stroke:#fff,color:#fff
Multiple components using the same composable — logic is shared, not duplicated

Before the Composition API, Vue 2 used mixins to reuse logic. But mixins had three serious issues: name collisions, implicit dependencies (unclear data origins), and poor TypeScript support. Composables solve all three cleanly.

CriterionMixins (Vue 2)Composables (Vue 3)
Name collisionsEasy to hit, complex merge strategiesNone — explicit destructuring
Source trackingYou don't know which mixin a field came fromClear import paths
TypeScriptLimited, poor inferenceFull type inference
CompositionHard to combine multiple mixinsCombine freely, no limits
TestingRequires mounting a componentTest directly or via withSetup

2. Anatomy — The Standard Structure of a Composable

A production-grade composable should follow a three-part structure: Primary State → Supportive State → Methods, with internal ordering: Initialization → Refs → Computed → Methods → Lifecycle hooks → Watchers.

// composables/useUserData.ts
import { ref, computed, onMounted, watch } from 'vue'
import type { Ref } from 'vue'

interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
}

type Status = 'idle' | 'loading' | 'success' | 'error'

export function useUserData(userId: Ref<string> | string) {
  // === 1. Primary State ===
  const user = ref<User | null>(null)

  // === 2. Supportive State ===
  const status = ref<Status>('idle')
  const error = ref<Error | null>(null)

  // === Computed ===
  const isAdmin = computed(() => user.value?.role === 'admin')
  const isLoading = computed(() => status.value === 'loading')

  // === 3. Methods ===
  const fetchUser = async (id: string) => {
    status.value = 'loading'
    error.value = null
    try {
      const response = await fetch(`/api/users/${id}`)
      user.value = await response.json()
      status.value = 'success'
    } catch (e) {
      error.value = e as Error
      status.value = 'error'
    }
  }

  // === Lifecycle ===
  onMounted(() => {
    const id = typeof userId === 'string' ? userId : userId.value
    fetchUser(id)
  })

  // === Watchers ===
  if (typeof userId !== 'string') {
    watch(userId, (newId) => fetchUser(newId))
  }

  return { user, status, error, isAdmin, isLoading, fetchUser }
}

Naming conventions

Always use the use prefix with PascalCase: useAuth, useCart, useDarkMode. Name the file the same way: useAuth.ts. When a composable takes more than three parameters, group them into an object: useUserData({ id, fetchOnMount: true, locale: 'en' }).

3. Core Patterns — Stateful vs Stateless vs Singleton

Three fundamental patterns govern how a composable manages state:

Stateless Composable

Each call creates its own state. Suited for logic local to a component.

export function useCounter(initial = 0) {
  const count = ref(initial)
  const increment = () => count.value++
  const decrement = () => count.value--
  return { count, increment, decrement }
}

// Component A: count = 0 (separate)
// Component B: count = 0 (separate)

Singleton / Shared State

State lives at module scope — every consumer shares the same instance. Good for lightweight global state.

const currentUser = ref<User | null>(null)
const isAuthenticated = computed(
  () => currentUser.value !== null
)

export function useAuth() {
  const login = async (credentials: Credentials) => {
    currentUser.value = await authApi.login(credentials)
  }
  const logout = () => { currentUser.value = null }

  return {
    currentUser: readonly(currentUser),
    isAuthenticated,
    login,
    logout
  }
}

// Every component sees the same currentUser

When to use Singleton vs Pinia?

Singleton composables fit simple state with few mutations (auth status, theme, locale). When state grows complex and you need devtools inspection, time-travel debugging, or SSR hydration — reach for a Pinia store. Rule of thumb: if you need $patch, $reset, or a plugin system → Pinia.

3.1 Factory Pattern — Composables that Create Composables

The factory pattern lets you create pre-configured composables, which is very useful when many modules share the same pattern but differ by base URL, headers, or transform logic:

// composables/createApiComposable.ts
function createApiComposable(baseUrl: string, defaultHeaders?: HeadersInit) {
  return function useApi<T>(endpoint: string) {
    const data = ref<T | null>(null)
    const error = ref<Error | null>(null)
    const isLoading = ref(false)

    const execute = async (options?: RequestInit) => {
      isLoading.value = true
      error.value = null
      try {
        const res = await fetch(`${baseUrl}${endpoint}`, {
          headers: { ...defaultHeaders, ...options?.headers },
          ...options
        })
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        data.value = await res.json()
      } catch (e) {
        error.value = e as Error
      } finally {
        isLoading.value = false
      }
    }

    return { data, error, isLoading, execute }
  }
}

// Create a composable per service
const useUserApi = createApiComposable('/api/v2/users')
const useProductApi = createApiComposable('/api/v2/products')

// In the component
const { data: users, execute } = useUserApi<User[]>('/')
const { data: product } = useProductApi<Product>('/123')

4. Advanced Patterns for Production

4.1 useAsyncState — The Standard Pattern for Async Operations

This is the most common pattern in production. Every API call, database interaction, or async operation needs loading/error/data state tracking:

// composables/useAsyncState.ts
export function useAsyncState<T>(
  asyncFn: () => Promise<T>,
  initialState: T,
  options: { immediate?: boolean } = { immediate: true }
) {
  const state = ref(initialState) as Ref<T>
  const isLoading = ref(false)
  const isReady = ref(false)
  const error = ref<Error | null>(null)

  const execute = async () => {
    isLoading.value = true
    error.value = null
    try {
      state.value = await asyncFn()
      isReady.value = true
    } catch (e) {
      error.value = e as Error
    } finally {
      isLoading.value = false
    }
  }

  if (options.immediate) execute()

  return { state, isLoading, isReady, error, execute }
}

// Usage
const { state: users, isLoading, error, execute: refresh } =
  useAsyncState(() => fetchUsers(), [])

4.2 useEventListener — Auto-cleanup Side Effects

The most important pattern for DOM interaction: automatically detach the event listener when the component unmounts to avoid memory leaks:

export function useEventListener<K extends keyof WindowEventMap>(
  target: EventTarget | Ref<EventTarget | null>,
  event: K,
  handler: (e: WindowEventMap[K]) => void,
  options?: AddEventListenerOptions
) {
  const cleanup = () => {
    const el = unref(target)
    el?.removeEventListener(event, handler as EventListener, options)
  }

  onMounted(() => {
    const el = unref(target)
    el?.addEventListener(event, handler as EventListener, options)
  })

  onUnmounted(cleanup)

  // If target is a ref, watch it to re-bind
  if (isRef(target)) {
    watch(target, (newTarget, oldTarget) => {
      oldTarget?.removeEventListener(event, handler as EventListener)
      newTarget?.addEventListener(event, handler as EventListener, options)
    })
  }

  return cleanup
}

4.3 useDebouncedRef — A Custom Reactivity Primitive

Vue lets you create custom refs via customRef(). This is the cleanest way to build a debounced input — no separate watch + setTimeout needed:

import { customRef } from 'vue'

export function useDebouncedRef<T>(initialValue: T, delay = 300) {
  let timeout: ReturnType<typeof setTimeout>

  return customRef<T>((track, trigger) => {
    let value = initialValue
    return {
      get() {
        track()
        return value
      },
      set(newValue: T) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
          value = newValue
          trigger()
        }, delay)
      }
    }
  })
}

// Template: v-model debounces itself
// <input v-model="searchQuery" />
const searchQuery = useDebouncedRef('', 400)

4.4 Cancellable Requests with AbortController

When a component unmounts or a user changes input quickly, the old request must be cancelled to avoid race conditions:

export function useCancellableFetch<T>(url: Ref<string>) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  let controller: AbortController | null = null

  const execute = async () => {
    controller?.abort()
    controller = new AbortController()

    try {
      const res = await fetch(url.value, {
        signal: controller.signal
      })
      data.value = await res.json()
    } catch (e) {
      if ((e as Error).name !== 'AbortError') {
        error.value = e as Error
      }
    }
  }

  watch(url, execute, { immediate: true })
  onUnmounted(() => controller?.abort())

  return { data, error }
}
sequenceDiagram
    participant U as User
    participant C as Component
    participant F as useCancellableFetch
    participant S as Server

    U->>C: Types "vue"
    C->>F: url = "/search?q=vue"
    F->>S: GET /search?q=vue
    U->>C: Types "vue compos" (fast)
    C->>F: url changes
    F->>F: abort() old request
    F->>S: GET /search?q=vue+compos
    S-->>F: ❌ Old request cancelled
    S-->>F: ✅ New response
    F-->>C: data = new result
AbortController guarantees only the latest response is processed — avoiding race conditions

5. VueUse v14 — The Production-Ready Composables Toolkit

VueUse is the largest composables library for Vue 3, shipping more than 300 ready-to-use composables. Version v14 (2025–2026) requires Vue 3.5+ and brings several important improvements:

ComposablePurposeWhat's new in v14
useIntersectionObserverTrack elements entering the viewportReactive rootMargin — changing margin no longer recreates the observer
useDraggableDrag-and-drop for elementsAuto-scroll inside scrollable containers
useDropZoneDrop zone for files/elementsValidation function for file type/size
useSortableDrag-to-reorder listswatchElement — auto re-init when the DOM changes
useCssSupportsDetect CSS feature supportBrand new — reactive CSS feature detection
useWebSocketWebSocket connectionFunction support for autoConnect.delay
useElementVisibilityWhether an element is visibleNew initialValue option

An example of using VueUse in production:

import {
  useIntersectionObserver,
  useDebounceFn,
  useLocalStorage,
  useMediaQuery,
  useOnline,
  useTitle
} from '@vueuse/core'

export function useSmartList() {
  // Persist scroll position in localStorage
  const scrollPos = useLocalStorage('list-scroll', 0)

  // Responsive breakpoint
  const isMobile = useMediaQuery('(max-width: 768px)')
  const pageSize = computed(() => isMobile.value ? 10 : 20)

  // Offline detection
  const isOnline = useOnline()

  // Infinite scroll trigger
  const loadMoreRef = ref<HTMLElement | null>(null)
  const isLoadMoreVisible = ref(false)

  useIntersectionObserver(loadMoreRef, ([{ isIntersecting }]) => {
    isLoadMoreVisible.value = isIntersecting
    if (isIntersecting && isOnline.value) {
      loadNextPage()
    }
  }, { rootMargin: '200px' })

  // Debounced search
  const search = useDebounceFn(async (query: string) => {
    // fetch results...
  }, 300)

  // Dynamic page title
  const totalItems = ref(0)
  useTitle(computed(() => `List (${totalItems.value}) | App`))

  const loadNextPage = async () => { /* ... */ }

  return {
    scrollPos, isMobile, pageSize, isOnline,
    loadMoreRef, search, totalItems
  }
}

6. Dependency Injection — provide/inject with Composables

When a composable needs an external service (API client, logger, config), dependency injection via provide/inject is the cleanest pattern — it avoids direct imports and makes mocking in tests trivial:

graph TB
    A["App Plugin
provide(apiKey, apiClient)"] --> B["Page Component"] B --> C["Child Component"] C --> D["Deep Nested Component"] B -->|"useProducts()"| E["Composable
inject(apiKey)"] C -->|"useProducts()"| E D -->|"useProducts()"| E style A fill:#2c3e50,stroke:#fff,color:#fff style E fill:#e94560,stroke:#fff,color:#fff style B fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
A plugin provides the service at the root → composables inject it at any depth — no prop drilling
// injection-keys.ts — Typed injection keys
import type { InjectionKey } from 'vue'

export interface ApiClient {
  get<T>(url: string): Promise<T>
  post<T>(url: string, body: unknown): Promise<T>
}

export const apiClientKey: InjectionKey<ApiClient> = Symbol('apiClient')
export const loggerKey: InjectionKey<Logger> = Symbol('logger')

// plugins/api.ts — Plugin provides the service
export const apiPlugin = {
  install(app: App) {
    const client: ApiClient = {
      async get(url) {
        const res = await fetch(url)
        return res.json()
      },
      async post(url, body) {
        const res = await fetch(url, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body)
        })
        return res.json()
      }
    }
    app.provide(apiClientKey, client)
  }
}

// composables/useProducts.ts — Composable injects the service
export function useProducts() {
  const api = inject(apiClientKey)
  if (!api) throw new Error('ApiClient not provided. Did you install apiPlugin?')

  const products = ref<Product[]>([])

  const fetchAll = async () => {
    products.value = await api.get<Product[]>('/api/products')
  }

  const create = async (product: CreateProductDto) => {
    const created = await api.post<Product>('/api/products', product)
    products.value.push(created)
    return created
  }

  return { products, fetchAll, create }
}

When to use provide/inject vs direct import?

Use provide/inject when: (1) you need to swap the implementation in tests, (2) service config changes per environment, (3) you want to avoid circular dependencies. Use a direct import when the composable is a pure utility that doesn't need mocking (for instance, useDebouncedRef, useCounter).

7. Performance — Tuning Reactivity for Scale

7.1 shallowRef for Large Datasets

With large lists (thousands of items), ref() creates a deep reactive proxy for every property of every object — expensive in memory and CPU. shallowRef only tracks changes to .value:

import { shallowRef, triggerRef } from 'vue'

export function useDataGrid<T>() {
  // ✅ shallowRef — no deep proxy over 10,000 rows
  const rows = shallowRef<T[]>([])

  const setData = (newRows: T[]) => {
    rows.value = newRows  // triggers re-render
  }

  const updateRow = (index: number, patch: Partial<T>) => {
    Object.assign(rows.value[index] as object, patch)
    triggerRef(rows)  // force trigger since mutations aren't auto-detected
  }

  // ❌ Do NOT use ref() for large datasets
  // const rows = ref([])  // Deep proxy over 10,000 objects = slow

  return { rows: readonly(rows), setData, updateRow }
}

7.2 watchEffect Cleanup — Avoiding Memory Leaks

watchEffect receives an onCleanup callback that runs before each re-execution and on component unmount. This pattern is crucial for async operations:

export function useRealtimeData(channel: Ref<string>) {
  const messages = ref<Message[]>([])

  watchEffect((onCleanup) => {
    const ws = new WebSocket(`wss://api.example.com/${channel.value}`)

    ws.onmessage = (e) => {
      messages.value.push(JSON.parse(e.data))
    }

    // Cleanup: close the old connection when channel changes
    // or when the component unmounts
    onCleanup(() => {
      ws.close()
    })
  })

  return { messages }
}

7.3 Computed Caching vs Method Calls

ApproachWhen it recomputesBest for
computed(() => ...)Only when a dependency changesDerived state used multiple times in the template
function filter() { ... }On every template re-renderLogic that needs different arguments each call
export function useFilteredList<T>(
  items: Ref<T[]>,
  predicate: (item: T) => boolean
) {
  // ✅ computed — recomputes only when items change
  const filtered = computed(() => items.value.filter(predicate))
  const count = computed(() => filtered.value.length)

  return { filtered, count }
}

Don't destructure reactive objects

const { x, y } = reactive({ x: 1, y: 2 })x and y lose reactivity. Use toRefs(): const { x, y } = toRefs(state). With a Pinia store: const { count } = storeToRefs(store).

8. Vue 3.5 — New APIs for Composables

Vue 3.5 (the baseline for VueUse v14) adds two important built-in composables:

8.1 useTemplateRef — Safer Template Refs

// Vue 3.4 and earlier — the ref name had to match the variable
const inputEl = ref<HTMLInputElement | null>(null)
// <input ref="inputEl" /> — variable name = ref name

// Vue 3.5+ — useTemplateRef with a string ID
const inputEl = useTemplateRef<HTMLInputElement>('my-input')
// <input ref="my-input" /> — ref name is a string, independent of the variable

// Supports dynamic refs
const currentRef = useTemplateRef<HTMLElement>(activeTab)

8.2 useId — SSR-safe Unique IDs

export function useFormField(label: string) {
  const id = useId()  // Unique and stable across SSR/client

  return {
    inputId: `field-${id}`,
    labelId: `label-${id}`,
    errorId: `error-${id}`,
    attrs: computed(() => ({
      id: `field-${id}`,
      'aria-labelledby': `label-${id}`,
      'aria-describedby': `error-${id}`
    }))
  }
}

// Each instance receives a distinct ID, identical on SSR and client hydration

9. Testing Composables with Vitest

Composable tests fall into two categories: direct invocation (no lifecycle required) and withSetup (needs onMounted, provide/inject).

9.1 Direct Test — Reactivity-only Composables

// useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('initializes with the correct value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

  it('increments and decrements correctly', () => {
    const { count, increment, decrement } = useCounter()
    increment()
    increment()
    expect(count.value).toBe(2)
    decrement()
    expect(count.value).toBe(1)
  })
})

9.2 withSetup Helper — Composables with Lifecycle

// test-utils.ts
import { createApp, type App } from 'vue'

export function withSetup<T>(composable: () => T): [T, App] {
  let result!: T
  const app = createApp({
    setup() {
      result = composable()
      return () => {}
    }
  })
  app.mount(document.createElement('div'))
  return [result, app]
}

// Test a composable with lifecycle hooks
import { describe, it, expect, afterEach } from 'vitest'
import { useWindowSize } from './useWindowSize'

describe('useWindowSize', () => {
  let app: App

  afterEach(() => app?.unmount())

  it('tracks window resize', async () => {
    const [result, _app] = withSetup(() => useWindowSize())
    app = _app

    // Simulate resize
    window.innerWidth = 1024
    window.dispatchEvent(new Event('resize'))

    expect(result.width.value).toBe(1024)
  })
})

9.3 Testing with Dependency Injection

export function withSetupAndProvide<T>(
  composable: () => T,
  provides: Record<string | symbol, unknown>
): [T, App] {
  let result!: T
  const app = createApp({
    setup() {
      for (const [key, val] of Object.entries(provides)) {
        provide(key, val)
      }
      // Handle Symbol keys
      for (const sym of Object.getOwnPropertySymbols(provides)) {
        provide(sym, provides[sym as unknown as string])
      }
      result = composable()
      return () => {}
    }
  })
  app.mount(document.createElement('div'))
  return [result, app]
}

// Test useProducts with a mocked API
describe('useProducts', () => {
  it('fetches products via the injected API client', async () => {
    const mockApi: ApiClient = {
      get: vi.fn().mockResolvedValue([{ id: 1, name: 'Test' }]),
      post: vi.fn()
    }

    const [result, app] = withSetupAndProvide(
      () => useProducts(),
      { [apiClientKey as unknown as string]: mockApi }
    )

    await result.fetchAll()
    expect(result.products.value).toHaveLength(1)
    expect(mockApi.get).toHaveBeenCalledWith('/api/products')

    app.unmount()
  })
})
pie title Composables Testing Strategy 2026
    "Integration Tests (Vitest Browser)" : 70
    "Composable Unit Tests" : 20
    "Visual/A11y Regression" : 10
The Inverted Testing Pyramid 2026 — prefer integration tests, reserve composable unit tests for complex logic

10. Composables Architecture in a Production Project

10.1 Functional Core, Imperative Shell

Split pure logic (pure functions, easy to test) from Vue reactivity (side effects, lifecycle):

// core/pricing.ts — Pure functions, NO Vue imports
export const calculateDiscount = (price: number, tier: 'bronze' | 'silver' | 'gold') => {
  const rates = { bronze: 0, silver: 0.1, gold: 0.2 }
  return price * (1 - rates[tier])
}

export const formatCurrency = (amount: number, locale = 'en-US') =>
  new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(amount)

// composables/usePricing.ts — Vue shell wrapping the pure core
import { calculateDiscount, formatCurrency } from '@/core/pricing'

export function usePricing(tier: Ref<'bronze' | 'silver' | 'gold'>) {
  const rawPrice = ref(0)

  const discountedPrice = computed(() =>
    calculateDiscount(rawPrice.value, tier.value)
  )

  const displayPrice = computed(() =>
    formatCurrency(discountedPrice.value)
  )

  return { rawPrice, discountedPrice, displayPrice }
}

Benefits of this pattern

calculateDiscount and formatCurrency are pure functions — testable with simple unit tests, no withSetup, no Vue context needed. The usePricing composable is just a thin wrapper bridging pure logic with reactivity.

src/
├── composables/           # Application-level composables
│   ├── useAuth.ts
│   ├── useProducts.ts
│   └── useNotifications.ts
├── composables/shared/    # Generic, reusable across projects
│   ├── useAsyncState.ts
│   ├── useEventListener.ts
│   ├── useDebouncedRef.ts
│   └── useCancellableFetch.ts
├── core/                  # Pure functions (no Vue dependency)
│   ├── pricing.ts
│   ├── validation.ts
│   └── formatting.ts
├── injection-keys.ts      # Typed InjectionKey definitions
└── plugins/
    ├── api.ts             # Provides ApiClient
    └── logger.ts          # Provides Logger

10.3 Composable Composition — Combining Small Composables into Domain Logic

// Small, single-responsibility composables
export function usePagination(pageSize = 20) {
  const page = ref(1)
  const total = ref(0)
  const totalPages = computed(() => Math.ceil(total.value / pageSize))
  const hasNext = computed(() => page.value < totalPages.value)
  const offset = computed(() => (page.value - 1) * pageSize)

  return { page, total, totalPages, hasNext, offset, pageSize }
}

// A domain composable combining several small composables
export function usePaginatedProducts() {
  const { page, total, totalPages, hasNext, offset, pageSize } = usePagination(12)
  const { state: products, isLoading, execute } = useAsyncState(
    () => fetchProducts({ offset: offset.value, limit: pageSize }),
    []
  )

  const searchQuery = useDebouncedRef('', 400)

  watch([page, searchQuery], () => execute())

  return {
    products, isLoading,
    page, totalPages, hasNext,
    searchQuery
  }
}

11. Checklist — Best Practices Summary

#PracticeDetails
1use prefixAlways name useSomething, with the file matching the name
2Return an object, not a tuplereturn { data, error } — consumers destructure by name, not position
3Accept Ref or plain valueUse MaybeRef<T> + unref() for flexible input
4Expose readonly statereturn { count: readonly(count) } — prevents unintended mutation
5Clean up side effectsonUnmounted, watchEffect(onCleanup) — avoid memory leaks
6Always surface error stateDon't swallow errors — expose an error ref for components to handle
7shallowRef for large dataLists of 100+ items: use shallowRef instead of ref
8Single ResponsibilityOne purpose per composable — combine via composition
9Pure core, reactive shellSeparate pure business logic from Vue reactivity
10Typed injection keysInjectionKey<T> for type safety when using provide/inject

Conclusion

Composables aren't just a way to organize code — they are the architectural foundation of modern Vue 3 applications. With patterns like factory, singleton, dependency injection, and functional core / imperative shell, you can build a codebase that is flexible, testable, and maintainable. VueUse v14 ships more than 300 ready-made composables, but knowing how to write a composable correctly remains a core skill that no library can replace.

Start by refactoring a mixin or a repeated chunk of logic in your current project into a composable — you'll immediately see the difference in code quality and developer experience.