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. Composable là gì và tại sao quan trọng?
- 2. Anatomy — Cấu trúc chuẩn của một Composable
- 3. Core Patterns — Stateful vs Stateless vs Singleton
- 4. Advanced Patterns cho Production
- 5. VueUse v14 — Bộ Toolkit Composables Production-Ready
- 6. Dependency Injection — provide/inject với Composables
- 7. Performance — Tối ưu Reactivity cho Scale
- 8. Vue 3.5 — API mới cho Composables
- 9. Testing Composables với Vitest
- 10. Kiến trúc Composables trong Dự án Production
- 11. Checklist — Best Practices tóm tắt
- Kết luận
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.
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
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 collision | Dễ xảy ra, merge strategy phức tạp | Không — destructure rõ ràng |
| Source tracking | Không biết data/method từ mixin nào | Import path rõ ràng |
| TypeScript | Hạn chế, thiếu inference | Full type inference |
| Composition | Khó kết hợp nhiều mixins | Kết hợp tự do, không giới hạn |
| Testing | Cần mount component | Test 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
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:
| Composable | Mục đích | Điểm mới v14 |
|---|---|---|
useIntersectionObserver | Theo dõi element vào viewport | rootMargin reactive — đổi margin không cần tạo lại observer |
useDraggable | Kéo thả element | Auto-scroll trong container có scroll |
useDropZone | Vùng thả file/element | Validation function cho file type/size |
useSortable | Sắp xếp danh sách kéo thả | watchElement — tự reinit khi DOM thay đổi |
useCssSupports | Detect CSS feature support | Mới hoàn toàn — reactive CSS feature detection |
useWebSocket | WebSocket connection | Function support cho autoConnect.delay |
useElementVisibility | Element hiển thị hay không | Option 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
// 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
| Approach | Khi nào tính lại | Phù hợp cho |
|---|---|---|
computed(() => ...) | Chỉ khi dependency thay đổi | Derived state dùng nhiều lần trong template |
function filter() { ... } | Mỗi lần template re-render | Logic 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 }) — x và y 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
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
calculateDiscount và formatCurrency 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
| # | Practice | Chi tiết |
|---|---|---|
| 1 | Prefix use | Luôn đặt tên useSomething, file cùng tên |
| 2 | Return object, không tuple | return { data, error } — consumer destructure theo tên, không theo vị trí |
| 3 | Accept Ref hoặc plain value | Dùng MaybeRef<T> + unref() cho input linh hoạt |
| 4 | Expose readonly state | return { count: readonly(count) } — ngăn mutation ngoài ý muốn |
| 5 | Cleanup side effects | onUnmounted, watchEffect(onCleanup) — tránh memory leak |
| 6 | Error state luôn có | Không nuốt lỗi — expose error ref để component xử lý |
| 7 | shallowRef cho large data | Danh sách >100 items: shallowRef thay ref |
| 8 | Single Responsibility | Mỗi composable một mục đích — kết hợp qua composition |
| 9 | Pure core, reactive shell | Tách business logic thuần khỏi Vue reactivity |
| 10 | Typed injection keys | InjectionKey<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.
Micro-Frontend 2026: Chia để trị Frontend với Module Federation 2.0
Tailwind CSS 4 và Oxide Engine: Khi CSS Framework được viết lại bằng Rust
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.