Vue.js 3 · Phần 5 — Composition API & Composable
Trích logic có trạng thái thành hàm useXxx() tái dùng được: anatomy của một composable, quy ước đặt tên/trả về, dọn dẹp với lifecycle, nhận tham số reactive, và vì sao composable thắng mixin.
Đây là phần làm nên giá trị thật của Composition API. Khi nhiều component cần cùng một logic có trạng thái — theo dõi chuột, fetch dữ liệu, đồng bộ localStorage — bạn không copy-paste. Bạn trích nó thành một composable: một hàm useXxx() đóng gói reactive state + logic, dùng lại ở bất kỳ component nào.
1. Composable là gì
Composable chỉ là một hàm thường, gọi được các API reactivity của Vue (ref, computed, watch, lifecycle), và trả về state + hàm cho component dùng. Quy ước: tên bắt đầu bằng use.
// composables/useCounter.ts
import { ref } from 'vue';
export function useCounter(initial = 0) {
const count = ref(initial);
const increment = () => count.value++;
const decrement = () => count.value--;
const reset = () => (count.value = initial);
return { count, increment, decrement, reset };
}
<script setup>
import { useCounter } from '@/composables/useCounter';
const { count, increment, reset } = useCounter(10);
</script>
<template>
<button @click="increment">{{ count }}</button>
<button @click="reset">Reset</button>
</template>
Mỗi lần gọi useCounter() tạo state độc lập — hai component dùng nó không chia sẻ count. Đó là điểm khác cốt lõi với store toàn cục (Pinia).
2. Composable có side effect & dọn dẹp
Sức mạnh thật khi composable quản lý cả side effect và tự dọn khi component bị hủy — gọi lifecycle ngay bên trong nó:
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue';
export function useMouse() {
const x = ref(0);
const y = ref(0);
function update(e: MouseEvent) {
x.value = e.clientX;
y.value = e.clientY;
}
onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update)); // tự gỡ listener
return { x, y };
}
Component dùng nó hoàn toàn không cần biết về addEventListener — composable lo trọn vòng đời. Đây là cách Vue giải quyết vấn đề mixin (rối ràng buộc, đụng tên) một cách sạch sẽ và tường minh.
3. Nhận tham số reactive
Composable thường nhận đầu vào có thể là giá trị tĩnh hoặc ref. Dùng toValue (Vue 3.3+) để chuẩn hóa và watchEffect để phản ứng khi đầu vào đổi:
// composables/useFetch.ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue';
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null);
const error = ref<unknown>(null);
const loading = ref(false);
watchEffect(async (onCleanup) => {
loading.value = true;
error.value = null;
const controller = new AbortController();
onCleanup(() => controller.abort()); // hủy request cũ khi url đổi
try {
const res = await fetch(toValue(url), { signal: controller.signal });
data.value = (await res.json()) as T;
} catch (e) {
if ((e as Error).name !== 'AbortError') error.value = e;
} finally {
loading.value = false;
}
});
return { data, error, loading };
}
const id = ref(1);
const { data, loading } = useFetch(() => `/api/users/${id.value}`);
// id.value = 2 → tự fetch lại, hủy request trước
MaybeRefOrGetter + toValue là quy ước hiện đại: API của bạn nhận được cả giá trị thường, ref, lẫn getter — linh hoạt mà vẫn reactive.
4. Quy ước trả về
- Trả về object các ref (như trên) để người dùng destructure đặt tên tùy ý và giữ reactivity (ref không mất reactivity khi destructure).
- Trả về hàm cho thao tác (
increment,refetch),computedcho giá trị dẫn xuất. - Đặt tên
useXxx. Một composable nên làm một việc — gom nhiều việc thì tách nhỏ và kết hợp chúng (đúng tinh thần “composition”).
// composables kết hợp được với nhau
export function useUserProfile(id: MaybeRefOrGetter<number>) {
const { data: user, loading } = useFetch<User>(() => `/api/users/${toValue(id)}`);
const isAdmin = computed(() => user.value?.role === 'admin');
return { user, loading, isAdmin };
}
5. Composable vs mixin vs util
| Composable | Mixin (Vue 2) | Hàm util thường | |
|---|---|---|---|
| Reactive state | có | có | không |
| Nguồn property | rõ (destructure) | ẩn (trộn vào this) | n/a |
| Trùng tên | không (bạn đặt) | dễ đụng | n/a |
| TypeScript | tốt | kém | tốt |
Composable thay thế mixin hoàn toàn. Hệ sinh thái VueUse là một thư viện composable khổng lồ (useLocalStorage, useDebounce, useIntersectionObserver…) — đáng dùng trước khi tự viết lại.
6. Bài tập
1. Vì sao hai component dùng useCounter() không chia sẻ cùng count?
Lời giải
Mỗi lần gọi hàm tạo ref mới trong phạm vi riêng. State cục bộ cho từng lần gọi. Muốn chia sẻ → đưa ref ra ngoài hàm (module scope) hoặc dùng Pinia.
2. Viết useLocalStorage(key, defaultValue) đồng bộ một ref với localStorage.
Lời giải
export function useLocalStorage<T>(key: string, def: T) {
const stored = localStorage.getItem(key);
const state = ref<T>(stored ? JSON.parse(stored) : def);
watch(state, (v) => localStorage.setItem(key, JSON.stringify(v)), { deep: true });
return state;
}3. Vì sao useFetch cần onCleanup(() => controller.abort())?
Lời giải
Khi url đổi nhanh, request cũ chưa xong vẫn chạy và có thể ghi đè kết quả mới (race condition). Abort request cũ trước khi chạy lần sau đảm bảo data luôn ứng với url hiện tại.
Điểm chính
- Composable = hàm
useXxx()đóng gói reactive state + logic + lifecycle, tái dùng được. - Mỗi lần gọi tạo state độc lập; gọi lifecycle bên trong để tự dọn side effect.
- Nhận đầu vào reactive bằng
MaybeRefOrGetter+toValue; phản ứng bằngwatchEffect. - Trả object các ref; mỗi composable làm một việc, kết hợp nhiều cái lại.
- Composable thay thế mixin; tận dụng VueUse trước khi tự viết.
Phần tiếp theo
Phần 6 — Form & v-model áp dụng composition vào bài toán thực tế nhất: input. Ta đi v-model trên mọi loại input, modifier, rồi dựng v-model cho component tùy biến (defineModel) để component của bạn dùng được như input gốc — và validate form sạch sẽ.