Skip to content

useApi và fetcher

useApi() trả về một $fetch đã cấu hình sẵn cho framework:

  • baseURL lấy từ runtimeConfig.public.apiBaseURL (mặc định /api) → mọi lời gọi đi qua BFF.
  • Chuyển tiếp cookie/header của request đang xử lý khi chạy trên server (useRequestFetch).
  • Chuẩn hoá mọi lỗi về AppError — kể cả lỗi mạng.
ts
const api = useApi()

const orders = await api<Paginated<Order>>('/orders', { query: { page: 1, pageSize: 20 } })
const order = await api<Order>('/orders/12')
const created = await api<Order>('/orders', { method: 'POST', body: payload })
await api(`/orders/${id}`, { method: 'DELETE' })

Tham số thứ hai là tuỳ chọn của ofetch: method, query, body, headers, signal, timeout, responseType

Đặt lời gọi vào một composable

Trang không gọi useApi trực tiếp. Mỗi nghiệp vụ có một composable đóng vai "repository": đổi đường dẫn, đổi định dạng hay thêm tham số chỉ sửa một chỗ, và trang thì test được bằng cách truyền fetcher giả.

ts
// app/composables/useOrdersApi.ts
import type { Order, OrderInput } from './orders.types'
import type { Paginated } from '@tasco/utils'
import type { TableQuery } from '@tasco/composables'

export function useOrdersApi() {
  const api = useApi()

  const fetchOrders = (query: TableQuery) =>
    api<Paginated<Order>>('/orders', { query: toOrdersQuery(query) })

  const fetchOrder = (id: string) => api<Order>(`/orders/${id}`)

  const createOrder = (payload: OrderInput) =>
    api<Order>('/orders', { method: 'POST', body: payload })

  const updateOrder = (id: string, payload: Partial<OrderInput>) =>
    api<Order>(`/orders/${id}`, { method: 'PATCH', body: payload })

  const removeOrder = (id: string) => api<void>(`/orders/${id}`, { method: 'DELETE' })

  return { fetchOrders, fetchOrder, createOrder, updateOrder, removeOrder }
}

Quy ước tên hàm: fetch* cho lời gọi HTTP, load* cho việc nạp dữ liệu vào state, map* cho hàm đổi dữ liệu ngoài sang kiểu nội bộ, to* cho chuyển đổi thuần.

Khi backend lệch hợp đồng

Backend nội bộ không phải lúc nào cũng theo hợp đồng dữ liệu của framework. Quy đổi ở fetcher — không sửa ở proxy, và cũng đừng để định dạng lạ lan vào component.

ts
/** Backend nhận `sort`/`order`, và bộ lọc ở cấp cao nhất. */
function toOrdersQuery(query: TableQuery) {
  return {
    page: query.page,
    pageSize: query.pageSize,
    sort: query.sortField,
    order: query.sortOrder === 'descend' ? 'desc' : query.sortOrder ? 'asc' : undefined,
    ...query.filters,
  }
}

/** Backend trả envelope `{ data: { list, totalCount } }` với field snake_case. */
interface OrderListResponse {
  data: { list: Array<{ id: number, order_code: string, created_at: string }>, totalCount: number }
}

async function fetchOrders(query: TableQuery): Promise<Paginated<Order>> {
  const res = await api<OrderListResponse>('/orders', { query: toOrdersQuery(query) })
  return {
    items: res.data.list.map(mapOrder),
    total: res.data.totalCount,
  }
}

function mapOrder(raw: OrderListResponse['data']['list'][number]): Order {
  return { id: raw.id, code: raw.order_code, createdAt: raw.created_at }
}

Kiểu snake_case chỉ tồn tại trong file này và được đặt tên riêng (OrderListResponse); phần còn lại của app chỉ thấy Order với field camelCase.

Dùng với useTable

useTable nhận đúng một fetcher (query: TableQuery) => Promise<Paginated<T>>:

ts
const { fetchOrders } = useOrdersApi()
const { dataSource, loading, pagination, onChange } = useTable<Order>(fetchOrders, { pageSize: 20 })

Xem Trang danh sách.

Dùng với useAsyncData

Cho dữ liệu tải một lần theo route (trang chi tiết, dashboard):

ts
const route = useRoute()
const { fetchOrder } = useOrdersApi()

const { data: order, status, error, refresh } = await useAsyncData(
  () => `order-${route.params.id}`,      // key đổi theo id → đổi route là tải lại
  () => fetchOrder(String(route.params.id)),
)

Key phải chứa mọi tham số ảnh hưởng tới kết quả. Key cố định trong khi tham số đổi là nguyên nhân kinh điển của "trang hiện dữ liệu của bản ghi trước".

Huỷ và timeout

ts
const controller = new AbortController()
onBeforeUnmount(() => controller.abort())

const data = await api('/orders/search', {
  method: 'POST',
  body: keyword,
  signal: controller.signal,
  timeout: 15_000,   // mặc định không giới hạn ở phía client
})

Ô tìm kiếm gõ liên tục nên huỷ lời gọi trước khi bắn lời gọi mới, tránh kết quả cũ về sau đè kết quả mới.

Tải tệp lên

ts
const form = new FormData()
form.append('file', file)
await api('/orders/import', { method: 'POST', body: form })

Không tự đặt Content-Type — trình duyệt cần tự sinh boundary.

Những điều không nên làm

ĐừngThay bằng
$fetch('https://gateway.../orders') từ tranguseApi() để đi qua BFF
Tự gắn Authorization ở clientBFF gắn — client không có token
Lưu token vào localStorage/storeCookie httpOnly do BFF quản lý
try/catch rồi nuốt lỗi im lặngĐể lỗi nổi lên hoặc gọi $tascoError(e) — xem Xử lý lỗi
Gọi API ngay trong component conNhận dữ liệu qua props; lời gọi thuộc về trang

Liên quan