Vue 3 Composables 2026 — Design Patterns, VueUse v14 và Kiến trúc Tái sử dụng cho Production

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

Table of contents

  1. 1. Composable là gì và tại sao quan trọng?
  2. 2. Anatomy — Cấu trúc chuẩn của một Composable
    1. Quy tắc đặt tên
  3. 3. Core Patterns — Stateful vs Stateless vs Singleton
    1. Stateless Composable
    2. Singleton / Shared State
    3. Khi nào dùng Singleton vs Pinia?
    4. 3.1 Factory Pattern — Composable tạo Composable
  4. 4. Advanced Patterns cho Production
    1. 4.1 useAsyncState — Pattern chuẩn cho Async Operations
    2. 4.2 useEventListener — Auto-cleanup Side Effects
    3. 4.3 useDebouncedRef — Custom Reactivity Primitive
    4. 4.4 Cancellable Requests với AbortController
  5. 5. VueUse v14 — Bộ Toolkit Composables Production-Ready
  6. 6. Dependency Injection — provide/inject với Composables
    1. Khi nào dùng provide/inject vs import trực tiếp?
  7. 7. Performance — Tối ưu Reactivity cho Scale
    1. 7.1 shallowRef cho Large Datasets
    2. 7.2 watchEffect Cleanup — Tránh Memory Leak
    3. 7.3 Computed Caching vs Method Calls
      1. Đừng destructure reactive objects
  8. 8. Vue 3.5 — API mới cho Composables
    1. 8.1 useTemplateRef — Template Refs an toàn hơn
    2. 8.2 useId — SSR-safe Unique IDs
  9. 9. Testing Composables với Vitest
    1. 9.1 Direct Test — Composable thuần reactivity
    2. 9.2 withSetup Helper — Composable có Lifecycle
    3. 9.3 Test với Dependency Injection
  10. 10. Kiến trúc Composables trong Dự án Production
    1. 10.1 Functional Core, Imperative Shell
      1. Lợi ích của pattern này
    2. 10.2 Cấu trúc thư mục khuyến nghị
    3. 10.3 Composable Composition — Kết hợp composables nhỏ thành domain logic
  11. 11. Checklist — Best Practices tóm tắt
  12. Kết luận
    1. Tài nguyên tham khảo

Composition API đã thay đổi hoàn toàn cách developer viết Vue. Nhưng sức mạnh thực sự không nằm ở ref() hay computed() — mà ở composables, những hàm tái sử dụng đóng gói logic, state và side-effect thành module độc lập. Bài viết này phân tích chuyên sâu các design patterns, kiến trúc và best practices cho Vue 3 composables trong production 2026, từ cấu trúc cơ bản đến advanced patterns như factory, singleton, dependency injection và testing.

VueUse v14300+ composables sẵn dùng
Vue 3.5+useTemplateRef & useId API mới
70%Integration test — chiến lược test 2026
VitestTesting framework chuẩn cho composables

1. Composable là gì và tại sao quan trọng?

Composable là một hàm bắt đầu bằng use, sử dụng Composition API để đóng gói và tái sử dụng stateful logic. Khác với utility function thuần túy, composable có thể chứa reactive state, computed properties, watchers và 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
Nhiều component cùng sử dụng một composable — logic được chia sẻ, không lặp lại

Trước Composition API, Vue 2 dùng mixins để tái sử dụng logic. Nhưng mixins có 3 vấn đề nghiêm trọng: xung đột tên (name collision), nguồn gốc dữ liệu không rõ ràng (implicit dependencies) và không hỗ trợ TypeScript tốt. Composables giải quyết triệt để cả 3.

Tiêu chíMixins (Vue 2)Composables (Vue 3)
Name collisionDễ xảy ra, merge strategy phức tạpKhông — destructure rõ ràng
Source trackingKhông biết data/method từ mixin nàoImport path rõ ràng
TypeScriptHạn chế, thiếu inferenceFull type inference
CompositionKhó kết hợp nhiều mixinsKết hợp tự do, không giới hạn
TestingCần mount componentTest trực tiếp hoặc withSetup

2. Anatomy — Cấu trúc chuẩn của một Composable

Một composable production-grade nên tuân theo cấu trúc 3 phần: Primary State → Supportive State → Methods, với thứ tự nội bộ: 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 }
}

Quy tắc đặt tên

Luôn dùng prefix use + PascalCase: useAuth, useCart, useDarkMode. File đặt cùng tên: useAuth.ts. Khi composable nhận hơn 3 tham số, gom vào object: useUserData({ id, fetchOnMount: true, locale: 'vi' }).

3. Core Patterns — Stateful vs Stateless vs Singleton

Ba pattern cơ bản quyết định cách composable quản lý state:

Stateless Composable

Mỗi lần gọi tạo state riêng. Phù hợp cho logic cục bộ củ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 (riêng)
// Component B: count = 0 (riêng)

Singleton / Shared State

State nằm ở module scope — tất cả consumer chia sẻ cùng một instance. Phù hợp cho global state nhẹ.

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
  }
}

// Tất cả component thấy cùng currentUser

Khi nào dùng Singleton vs Pinia?

Singleton composable phù hợp cho state đơn giản, ít mutation (auth status, theme, locale). Khi state phức tạp, cần devtools inspection, time-travel debugging hoặc SSR hydration — hãy dùng Pinia store. Quy tắc: nếu bạn cần $patch, $reset hoặc plugin system → Pinia.

3.1 Factory Pattern — Composable tạo Composable

Factory pattern cho phép tạo composable được cấu hình sẵn (pre-configured), rất hữu ích khi nhiều module cùng dùng một pattern nhưng khác base URL, headers hoặc 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 }
  }
}

// Tạo composable cho từng service
const useUserApi = createApiComposable('/api/v2/users')
const useProductApi = createApiComposable('/api/v2/products')

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

4. Advanced Patterns cho Production

4.1 useAsyncState — Pattern chuẩn cho Async Operations

Đây là pattern xuất hiện thường xuyên nhất trong production. Mọi tương tác với API, database, hoặc async operation đều cần quản lý trạng thái loading/error/data:

// 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 }
}

// Sử dụng
const { state: users, isLoading, error, execute: refresh } =
  useAsyncState(() => fetchUsers(), [])

4.2 useEventListener — Auto-cleanup Side Effects

Pattern quan trọng nhất cho DOM interaction: tự động gỡ event listener khi component unmount, tránh memory leak:

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)

  // Nếu target là ref, watch để 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 — Custom Reactivity Primitive

Vue cho phép tạo custom ref qua customRef(). Đây là cách tối ưu nhất để tạo debounced input — không cần watch + setTimeout riêng:

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 tự debounce
// <input v-model="searchQuery" />
const searchQuery = useDebouncedRef('', 400)

4.4 Cancellable Requests với AbortController

Khi component unmount hoặc user thay đổi input nhanh, request cũ phải bị hủy để tránh race condition:

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: Nhập "vue"
    C->>F: url = "/search?q=vue"
    F->>S: GET /search?q=vue
    U->>C: Nhập "vue compos" (nhanh)
    C->>F: url thay đổi
    F->>F: abort() request cũ
    F->>S: GET /search?q=vue+compos
    S-->>F: ❌ Request cũ bị hủy
    S-->>F: ✅ Response mới
    F-->>C: data = kết quả mới
AbortController đảm bảo chỉ response mới nhất được xử lý — tránh race condition

5. VueUse v14 — Bộ Toolkit Composables Production-Ready

VueUse là thư viện composables lớn nhất cho Vue 3, cung cấp hơn 300 composables sẵn dùng. Phiên bản v14 (2025–2026) yêu cầu Vue 3.5+ và mang đến nhiều cải tiến quan trọng:

ComposableMục đíchĐiểm mới v14
useIntersectionObserverTheo dõi element vào viewportrootMargin reactive — đổi margin không cần tạo lại observer
useDraggableKéo thả elementAuto-scroll trong container có scroll
useDropZoneVùng thả file/elementValidation function cho file type/size
useSortableSắp xếp danh sách kéo thảwatchElement — tự reinit khi DOM thay đổi
useCssSupportsDetect CSS feature supportMới hoàn toàn — reactive CSS feature detection
useWebSocketWebSocket connectionFunction support cho autoConnect.delay
useElementVisibilityElement hiển thị hay khôngOption initialValue mới

Ví dụ sử dụng VueUse trong production:

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

export function useSmartList() {
  // Lưu scroll position vào 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(() => `Danh sách (${totalItems.value}) | App`))

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

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

6. Dependency Injection — provide/inject với Composables

Khi composable cần service bên ngoài (API client, logger, config), dependency injection qua provide/inject là pattern sạch nhất — tránh import trực tiếp, dễ mock khi test:

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
Plugin provide service ở root → composable inject ở bất kỳ depth nào — không cần 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 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 inject 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 }
}

Khi nào dùng provide/inject vs import trực tiếp?

Dùng provide/inject khi: (1) cần swap implementation trong test, (2) service config thay đổi theo môi trường, (3) tránh circular dependency. Dùng import trực tiếp khi: composable là pure utility không cần mock (ví dụ useDebouncedRef, useCounter).

7. Performance — Tối ưu Reactivity cho Scale

7.1 shallowRef cho Large Datasets

Với danh sách lớn (hàng nghìn items), ref() tạo deep reactive proxy cho mọi property của mọi object — tốn bộ nhớ và CPU. shallowRef chỉ track .value thay đổi:

import { shallowRef, triggerRef } from 'vue'

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

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

  const updateRow = (index: number, patch: Partial<T>) => {
    Object.assign(rows.value[index] as object, patch)
    triggerRef(rows)  // force trigger vì mutation không tự detect
  }

  // ❌ KHÔNG dùng ref() cho large dataset
  // const rows = ref([])  // Deep proxy 10,000 objects = chậm

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

7.2 watchEffect Cleanup — Tránh Memory Leak

watchEffect nhận callback onCleanup chạy trước mỗi lần re-execute và khi component unmount. Pattern này cực kỳ quan trọng cho 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: đóng connection cũ khi channel thay đổi
    // hoặc khi component unmount
    onCleanup(() => {
      ws.close()
    })
  })

  return { messages }
}

7.3 Computed Caching vs Method Calls

ApproachKhi nào tính lạiPhù hợp cho
computed(() => ...)Chỉ khi dependency thay đổiDerived state dùng nhiều lần trong template
function filter() { ... }Mỗi lần template re-renderLogic cần tham số khác nhau mỗi lần gọi
export function useFilteredList<T>(
  items: Ref<T[]>,
  predicate: (item: T) => boolean
) {
  // ✅ computed — chỉ tính lại khi items thay đổi
  const filtered = computed(() => items.value.filter(predicate))
  const count = computed(() => filtered.value.length)

  return { filtered, count }
}

Đừng destructure reactive objects

const { x, y } = reactive({ x: 1, y: 2 })xy mất reactivity. Dùng toRefs(): const { x, y } = toRefs(state). Với Pinia store: const { count } = storeToRefs(store).

8. Vue 3.5 — API mới cho Composables

Vue 3.5 (baseline cho VueUse v14) bổ sung 2 composable built-in quan trọng:

8.1 useTemplateRef — Template Refs an toàn hơn

// Vue 3.4 trở về trước — ref name phải khớp biến
const inputEl = ref<HTMLInputElement | null>(null)
// <input ref="inputEl" /> — tên biến = tên ref

// Vue 3.5+ — useTemplateRef với string ID
const inputEl = useTemplateRef<HTMLInputElement>('my-input')
// <input ref="my-input" /> — tên ref là string, không phụ thuộc tên biến

// Hỗ trợ dynamic refs
const currentRef = useTemplateRef<HTMLElement>(activeTab)

8.2 useId — SSR-safe Unique IDs

export function useFormField(label: string) {
  const id = useId()  // Unique, 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}`
    }))
  }
}

// Mỗi instance nhận ID khác nhau, đồng nhất giữa SSR và client hydration

9. Testing Composables với Vitest

Composables chia thành 2 loại test: direct invocation (không cần lifecycle) và withSetup (cần onMounted, provide/inject).

9.1 Direct Test — Composable thuần reactivity

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

describe('useCounter', () => {
  it('khởi tạo đúng giá trị', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

  it('increment và decrement chính xác', () => {
    const { count, increment, decrement } = useCounter()
    increment()
    increment()
    expect(count.value).toBe(2)
    decrement()
    expect(count.value).toBe(1)
  })
})

9.2 withSetup Helper — Composable có 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 composable có lifecycle
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 Test với 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)
      }
      // Xử lý 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 với mock API
describe('useProducts', () => {
  it('fetches products via 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 Chiến lược Testing Composables 2026
    "Integration Tests (Vitest Browser)" : 70
    "Composable Unit Tests" : 20
    "Visual/A11y Regression" : 10
Inverted Testing Pyramid 2026 — ưu tiên integration test, composable unit test cho logic phức tạp

10. Kiến trúc Composables trong Dự án Production

10.1 Functional Core, Imperative Shell

Tách logic thuần (pure functions, dễ test) khỏi Vue reactivity (side effects, lifecycle):

// core/pricing.ts — Pure functions, KHÔNG import Vue
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 = 'vi-VN') =>
  new Intl.NumberFormat(locale, { style: 'currency', currency: 'VND' }).format(amount)

// composables/usePricing.ts — Vue shell, wraps 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 }
}

Lợi ích của pattern này

calculateDiscountformatCurrency là pure functions — test bằng unit test đơn giản, không cần withSetup, không cần Vue context. Composable usePricing chỉ là lớp wrapper mỏng nối pure logic với reactivity.

10.2 Cấu trúc thư mục khuyến nghị

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 — Kết hợp composables nhỏ thành domain logic

// Composable nhỏ, single-responsibility
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 }
}

// Domain composable kết hợp nhiều composable nhỏ
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 tóm tắt

#PracticeChi tiết
1Prefix useLuôn đặt tên useSomething, file cùng tên
2Return object, không tuplereturn { data, error } — consumer destructure theo tên, không theo vị trí
3Accept Ref hoặc plain valueDùng MaybeRef<T> + unref() cho input linh hoạt
4Expose readonly statereturn { count: readonly(count) } — ngăn mutation ngoài ý muốn
5Cleanup side effectsonUnmounted, watchEffect(onCleanup) — tránh memory leak
6Error state luôn cóKhông nuốt lỗi — expose error ref để component xử lý
7shallowRef cho large dataDanh sách >100 items: shallowRef thay ref
8Single ResponsibilityMỗi composable một mục đích — kết hợp qua composition
9Pure core, reactive shellTách business logic thuần khỏi Vue reactivity
10Typed injection keysInjectionKey<T> cho type safety khi dùng provide/inject

Kết luận

Composables không chỉ là cách tổ chức code — chúng là nền tảng kiến trúc của ứng dụng Vue 3 hiện đại. Với các pattern như factory, singleton, dependency injection, và functional core / imperative shell, bạn có thể xây dựng codebase vừa linh hoạt, vừa dễ test và dễ bảo trì. VueUse v14 cung cấp hơn 300 composable sẵn dùng, nhưng hiểu cách viết composable đúng chuẩn vẫn là kỹ năng cốt lõi mà không thư viện nào thay thế được.

Hãy bắt đầu bằng việc refactor một mixin hoặc đoạn logic lặp lại trong dự án hiện tại thành composable — bạn sẽ thấy ngay sự khác biệt về code quality và developer experience.