27/06/2026 · 6 phút đọc
Phân tích sâu TanStack Query v5: Kiến trúc Production-Ready cho dự án Frontend lớn
Kiến trúc hóa quy trình quản lý State bất đồng bộ với TanStack Query v5 cho dự án Enterprise: Tổ chức thư mục theo Feature, chuẩn hóa API Client và xử lý trọn gói CRUD.
Disclaimer: Thông tin được cung cấp trên trang web này chỉ mang tính chất tham khảo chung, tìm hiểu công nghệ mới.
thienlm.comkhông chịu trách nhiệm về bất kỳ lỗi hoặc thiếu sót nào trong nội dung (nếu có). Hãy cẩn trọng trong việc tìm hiểu thông tin. Nếu muốn góp ý thêm với mình về một vấn đề nào đó, vui lòng gửi liên hệ đến mình tại đây. Cảm ơn bạn đã dành thời gian ở đây. Peace 🍀
Khi làm việc với các dự án lớn, việc sử dụng TanStack Query (React Query) một cách tùy tiện, viết inline useQuery hay rải rác queryKey ở khắp các component sẽ nhanh chóng biến codebase của bạn thành một bãi rác. Đặc biệt với phiên bản mới nhất (v5), TanStack Query mang lại nhiều thay đổi mang tính bước ngoặt (như đơn giản hóa API, tối ưu hóa cơ chế tracking và khuyến khích sử dụng queryOptions).
Bài viết chuyên sâu này sẽ phân tích cách thiết lập một kiến trúc Production-Ready cho một feature phức tạp (Feature: Task), giải quyết triệt để các bài toán:
- Tự động chuyển đổi dữ liệu giữa CamelCase (Frontend) và SnakeCase (Backend).
- Tổ chức thư mục theo Feature-driven khoa học, dễ mở rộng.
- Tách biệt hoàn toàn:
Services,Keys,Options, vàHooks. - Xử lý trọn gói CRUD và Infinite Scroll (Pagination dựa trên Cursor/NextPage).
1. Kiến Trúc Thư Mục Cho Các Dự Án Lớn
Đối với cấu trúc Enterprise, ta sẽ gom toàn bộ logic của một Feature vào một phân vùng quản lý riêng, tránh việc tạo thư mục hooks chung chung chứa hàng trăm file.
src/
├── api/
│ ├── axios-client.ts # Cấu hình Axios, Interceptors, Camel/Snake transform
│ └── api-helper.ts # Wrapper hàm request chuẩn hóa
├── features/
│ └── task/
│ ├── components/ # UI Components (TaskList, TaskCard,...)
│ ├── services/
│ │ └── task.service.ts # Class TaskService tương tác với API Gateway
│ └── queries/ # Toàn bộ trạng thái bất đồng bộ nằm ở đây
│ ├── index.ts # Export công khai các hooks ra bên ngoài
│ ├── task.keys.ts # Tập trung quản lý Query Keys
│ ├── task.options.ts # Định nghĩa các queryOptions (v5)
│ ├── task.mutations.ts# Định nghĩa các mutationOptions
│ ├── use-task-queries.ts # Custom hooks cho useQuery / useInfiniteQuery
│ └── use-task-mutations.ts # Custom hooks cho useMutation
2. Lớp Hạ Tầng: axios-client.ts & api-helper.ts
Backend (đặc biệt là Ruby on Rails, Django, Laravel) thường sử dụng cấu trúc snake_case, trong khi Frontend JavaScript/TypeScript lại quy ước camelCase. Chúng ta sẽ tự động hóa quy trình mapping này ở tầng Interceptor bằng các thư viện camelcase-keys và snakecase-keys.
Tải các thư viện cần thiết
pnpm add axios @tanstack/react-query js-cookie camelcase-keys snakecase-keys
pnpm add -D @types/js-cookie
Nội dung file axios-client.ts
Cấu trúc đường dẫn đề xuất: src/api/axios-client.ts
import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import camelcaseKeys from 'camelcase-keys'
import snakecaseKeys from 'snakecase-keys'
import Cookies from 'js-cookie'
export const axiosClient = axios.create({
baseURL:
process.env.NEXT_PUBLIC_API_URL || '[https://api.thienlm.com/v1](https://api.thienlm.com/v1)',
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
})
// Request Interceptor: Gán Token & Chuyển đổi dữ liệu sang snake_case trước khi gửi lên Server
axiosClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Lấy Token từ Cookie hoặc Zustand Store tùy cấu trúc dự án của bạn
const token = Cookies.get('access_token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
// Tự động convert Body Data sang snake_case
if (config.data && !(config.data instanceof FormData)) {
config.data = snakecaseKeys(config.data, { deep: true })
}
// Tự động convert URL Params sang snake_case
if (config.params) {
config.params = snakecaseKeys(config.params, { deep: true })
}
return config
},
(error) => Promise.reject(error),
)
// Response Interceptor: Tự động chuyển đổi dữ liệu nhận về thành camelCase cho Frontend sử dụng
axiosClient.interceptors.response.use(
(response: AxiosResponse) => {
if (response.data && response.headers['content-type']?.includes('application/json')) {
response.data = camelcaseKeys(response.data, { deep: true })
}
return response
},
(error) => {
// Xử lý Global Error (ví dụ: Refresh Token hoặc Redirect khi 401)
if (error.response?.status === 401) {
// Thực hiện logic logout hoặc refresh token tại đây
}
return Promise.reject(error)
},
)
⚠️ Lưu ý đặc biệt quan trọng: Cạm bẫy với FormData và File
Khi cấu hình tự động chuyển đổi dữ liệu với flag { deep: true }, hai thư viện camelcase-keys và snakecase-keys sẽ duyệt đệ quy qua toàn bộ các tầng thuộc tính của Object. Điều này vô tình trở thành một thảm họa nếu payload của bạn có chứa các đối tượng nhị phân như File, Blob hoặc định dạng FormData (thường gặp khi làm tính năng upload ảnh đại diện, tài liệu, v.v.).
Việc duyệt sâu vào các thuộc tính nội bộ của một file nhị phân sẽ làm thay đổi cấu trúc dữ liệu của file đó, dẫn đến việc gửi lên Server bị lỗi (Corrupted File) hoặc Server không thể giải mã được.
Giải pháp triệt để cho Production:
Trong đoạn Request Interceptor, chúng ta đã có một màng lọc cơ bản để bỏ qua FormData:
if (config.data && !(config.data instanceof FormData)) {
config.data = snakecaseKeys(config.data, { deep: true })
}
Tuy nhiên, nếu bạn gửi một JSON Object thông thường nhưng bên trong có chứa một trường là File hoặc Blob (chưa bọc qua FormData), bạn cần bổ sung cấu hình loại trừ (exclude) các key đó để bảo vệ dữ liệu nhị phân:
config.data = snakecaseKeys(config.data, {
deep: true,
// Thêm các trường dữ liệu nhị phân bạn muốn giữ nguyên cấu hình vào đây
exclude: ['avatar_file', 'document_blob', 'attachment'],
})
Nội dung file api-helper.ts
Cấu trúc đường dẫn đề xuất: src/api/api-helper.ts
Tạo một wrapper mỏng giúp ép kiểu dữ liệu trả về một cách tường minh, đồng thời cô lập lớp Axios phòng trường hợp tương lai muốn thay thế bằng fetch hoặc hono/client.
import { AxiosRequestConfig } from 'axios'
import { axiosClient } from './axios-client'
export const request = {
get: <T>(url: string, config?: AxiosRequestConfig): Promise<T> =>
axiosClient.get(url, config).then((res) => res.data),
post: <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> =>
axiosClient.post(url, data, config).then((res) => res.data),
put: <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> =>
axiosClient.put(url, data, config).then((res) => res.data),
patch: <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> =>
axiosClient.patch(url, data, config).then((res) => res.data),
delete: <T>(url: string, config?: AxiosRequestConfig): Promise<T> =>
axiosClient.delete(url, config).then((res) => res.data),
}
3. Tầng Nghiệp Vụ: Định Nghĩa Các Types & TaskService
Định nghĩa type:
Cấu trúc đường dẫn đề xuất: src/features/task/types/index.ts
export interface Task {
id: string
title: string
description: string
status: 'todo' | 'in_progress' | 'done'
createdAt: string
updatedAt: string
}
export interface CreateTaskDto {
title: string
description: string
}
export interface UpdateTaskDto {
title?: string
description?: string
status?: 'todo' | 'in_progress' | 'done'
}
export interface TaskFilters {
status?: Task['status']
search?: string
}
export interface PaginatedResponse<T> {
data: T[]
nextPage: number | null
nextCursor: string | null
limit: number
}
Định nghĩa service:
Cấu trúc đường dẫn đề xuất: src/features/task/services/task.service.ts
import { request } from '@/api/api-helper'
import { Task, CreateTaskDto, UpdateTaskDto, TaskFilters, PaginatedResponse } from '../types'
export class TaskService {
private static readonly ENDPOINT = '/tasks'
static getList(filters?: TaskFilters) {
return request.get<Task[]>(this.ENDPOINT, { params: filters })
}
static getInfiniteList(params: {
pageParam?: string | number | undefined
limit: number
status?: string
}) {
return request.get<PaginatedResponse<Task>>(this.ENDPOINT, {
params: {
cursor: params.pageParam, // hoặc page: params.pageParam
limit: params.limit,
status: params.status,
},
})
}
static getDetail(id: string) {
return request.get<Task>(`${this.ENDPOINT}/${id}`)
}
static create(dto: CreateTaskDto) {
return request.post<Task>(this.ENDPOINT, dto)
}
static update(id: string, dto: UpdateTaskDto) {
return request.patch<Task>(`${this.ENDPOINT}/${id}`, dto)
}
static delete(id: string) {
return request.delete<void>(`${this.ENDPOINT}/${id}`)
}
}
4. Tầng Quản Lý Trạng Thái: Cấu Trúc File Hệ Thống Thư Mục queries/
4.1. Định nghĩa queryKey:
Cấu trúc đường dẫn đề xuất: src/features/task/queries/task.keys.ts
Quản lý tập trung queryKey dưới dạng một Object hoặc Factory để tránh việc gõ sai string key giữa các file.
import { TaskFilters } from '../types'
export const taskKeys = {
all: ['tasks'] as const,
lists: () => [...taskKeys.all, 'list'] as const,
list: (filters: TaskFilters) => [...taskKeys.lists(), 'paginated', { filters }] as const,
infiniteList: (filters: TaskFilters & { limit: number }) =>
[...taskKeys.lists(), 'infinite', { filters }] as const,
details: () => [...taskKeys.all, 'detail'] as const,
detail: (id: string) => [...taskKeys.details(), id] as const,
}
4.2. Định nghĩa queryOptions:
Cấu trúc đường dẫn đề xuất: src/features/task/queries/task.options.ts
Trong TanStack Query v5, phương pháp khai báo tập trung bằng queryOptions được khuyến khích tối đa. Nó giúp suy luận kiểu (Type Inference) chuẩn xác và dễ dàng tái sử dụng cấu hình khi gọi queryClient.fetchQuery hoặc queryClient.ensureQueryData lúc làm Server-Side Rendering (SSR).
import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query'
import { TaskService } from '../services/task.service'
import { taskKeys } from './task.keys'
import { TaskFilters } from '../types'
export const taskQueryOptions = {
list: (filters: TaskFilters) =>
queryOptions({
queryKey: taskKeys.list(filters),
queryFn: () => TaskService.getList(filters),
staleTime: 5 * 60 * 1000, // 5 phút dữ liệu không bị coi là cũ
}),
infiniteList: (filters: TaskFilters, limit: number = 10) =>
infiniteQueryOptions({
queryKey: taskKeys.infiniteList({ ...filters, limit }),
queryFn: ({ pageParam }) =>
TaskService.getInfiniteList({
pageParam,
limit,
status: filters.status,
}),
initialPageParam: undefined as string | number | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? lastPage.nextPage ?? undefined,
staleTime: 2 * 60 * 1000,
}),
detail: (id: string) =>
queryOptions({
queryKey: taskKeys.detail(id),
queryFn: () => TaskService.getDetail(id),
enabled: !!id, // Chỉ kích hoạt khi có ID hợp lệ
}),
}
4.3. Định nghĩa mutation:
Cấu trúc đường dẫn đề xuất: src/features/task/queries/task.mutations.ts
Tương tự như queryOptions, chúng ta đóng gói cấu hình cho các thao tác ghi dữ liệu (CUD).
- Cách 1: Định nghĩa kiểu dữ liệu tường minh (Khuyên dùng) Cách này giúp IDE tự động gợi ý các hàm callback như onSuccess, onError, onMutate một cách chính xác khi bạn gọi hook.
import { UseMutationOptions } from '@tanstack/react-query'
import { TaskService } from '../services/task.service'
import { CreateTaskDto, UpdateTaskDto, Task } from '../types'
export const taskMutationOptions = {
create: (): UseMutationOptions<Task, Error, CreateTaskDto> => ({
mutationFn: (dto: CreateTaskDto) => TaskService.create(dto),
}),
update: (id: string): UseMutationOptions<Task, Error, UpdateTaskDto> => ({
mutationFn: (dto: UpdateTaskDto) => TaskService.update(id, dto),
}),
delete: (): UseMutationOptions<void, Error, string> => ({
mutationFn: (id: string) => TaskService.delete(id),
}),
}
(Trong đó cấu trúc generic là <TData, TError, TVariables>)
- Cách 2: Sử dụng một Factory Function trả về Plain Object Nếu bạn muốn giữ code ngắn gọn mà không cần import quá nhiều Type phức tạp, bạn chỉ cần trả về một plain object. Khi truyền object này vào useMutation, TypeScript vẫn tự động suy luận kiểu (Infer) cho bạn
import { TaskService } from '../services/task.service'
import { CreateTaskDto, UpdateTaskDto } from '../types'
export const taskMutationOptions = {
create: () => ({
mutationFn: (dto: CreateTaskDto) => TaskService.create(dto),
}),
update: (id: string) => ({
mutationFn: (dto: UpdateTaskDto) => TaskService.update(id, dto),
}),
delete: () => ({
mutationFn: (id: string) => TaskService.delete(id),
}),
}
Khi sử dụng ở file use-task-mutations.ts, bạn chỉ cần rải (spread) object này ra là xong:
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { taskMutationOptions } from './task.mutations'
import { taskKeys } from './task.keys'
export const useCreateTaskMutation = () => {
const queryClient = useQueryClient()
return useMutation({
...taskMutationOptions.create(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: taskKeys.lists() })
},
})
}
4.4. Định nghĩa các hook query
Cấu trúc đường dẫn đề xuất: src/features/task/queries/use-task-queries.ts
Khai báo các custom hooks đóng gói logic hiển thị để cung cấp trực tiếp cho giao diện UI.
import { useQuery, useInfiniteQuery } from '@tanstack/react-query'
import { taskQueryOptions } from './task.options'
import { TaskFilters } from '../types'
export const useTaskListQuery = (filters: TaskFilters) => {
return useQuery(taskQueryOptions.list(filters))
}
export const useTaskInfiniteQuery = (filters: TaskFilters, limit?: number) => {
return useInfiniteQuery(taskQueryOptions.infiniteList(filters, limit))
}
export const useTaskDetailQuery = (id: string) => {
return useQuery(taskQueryOptions.detail(id))
}
4.5. Định nghĩa các hook mutation
Cấu trúc đường dẫn đề xuất: src/features/task/queries/use-task-mutations.ts
Nơi xử lý luồng đồng bộ UI sau khi ghi/sửa dữ liệu thành công (ví dụ: làm mới danh sách bằng invalidateQueries hoặc cập nhật trực tiếp Cache qua setQueryData).
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { taskMutationOptions } from './task.mutations'
import { taskKeys } from './task.keys'
export const useCreateTaskMutation = () => {
const queryClient = useQueryClient()
return useMutation({
...taskMutationOptions.create(),
onSuccess: () => {
// Làm mới toàn bộ danh sách Task đang hiển thị ở UI
queryClient.invalidateQueries({ queryKey: taskKeys.lists() })
},
})
}
export const useUpdateTaskMutation = (id: string) => {
const queryClient = useQueryClient()
return useMutation({
...taskMutationOptions.update(id),
onSuccess: (updatedTask) => {
// Tối ưu hóa UI: Cập nhật trực tiếp cache của bản ghi chi tiết mà không cần gọi lại API
queryClient.setQueryData(taskKeys.detail(id), updatedTask)
queryClient.invalidateQueries({ queryKey: taskKeys.lists() })
},
})
}
export const useDeleteTaskMutation = () => {
const queryClient = useQueryClient()
return useMutation({
...taskMutationOptions.delete(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: taskKeys.all })
},
})
}
4.6. Tổng hợp file để export ra ngoài
Cấu trúc đường dẫn đề xuất: src/features/task/queries/index.ts
Cuối cùng, gom tất cả các hooks vào file entrypoint của thư mục để export ra bên ngoài gọn gàng.
export * from './use-task-queries'
export * from './use-task-mutations'
export { taskKeys } from './task.keys'
5. Sử Dụng Thực Tế Trong Component UI
Bằng việc tổ chức chặt chẽ như trên, code tại Component UI của bạn sẽ vô cùng ngắn gọn, sạch sẽ, hoàn toàn không dính dáng đến cú pháp cấu hình API phức tạp.
Ví dụ: Component quản lý danh sách vô hạn (Infinite Scroll) & Tạo Task mới
import React, { useState } from 'react'
import { useTaskInfiniteQuery, useCreateTaskMutation } from '../queries'
export const TaskDashboard: React.FC = () => {
const [search, setSearch] = useState('')
// Sử dụng Custom Hook Infinite Query
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } =
useTaskInfiniteQuery({ search }, 10)
// Sử dụng Custom Hook Mutation
const createTaskMutation = useCreateTaskMutation()
const handleCreateTask = async () => {
try {
await createTaskMutation.mutateAsync({
title: 'Học thiết kế hệ thống với TanStack Query',
description: 'Đọc kỹ tài liệu cấu trúc thư mục enterprise.',
})
alert('Tạo task thành công!')
} catch (error) {
console.error('Lỗi khi tạo task:', error)
}
}
if (isLoading) return <div>Đang tải dữ liệu...</div>
if (isError) return <div>Có lỗi hệ thống xảy ra!</div>
return (
<div className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Quản lý Công việc</h1>
<button
onClick={handleCreateTask}
disabled={createTaskMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
{createTaskMutation.isPending ? 'Đang xử lý...' : 'Thêm Task Mới'}
</button>
<div className="space-y-2">
{data?.pages.map((page, index) => (
<React.Fragment key={index}>
{page.data.map((task) => (
<div key={task.id} className="p-4 border border-gray-200 rounded-lg shadow-sm">
<h3 className="font-semibold">{task.title}</h3>
<p className="text-gray-600 text-sm">{task.description}</p>
<span className="text-xs text-blue-500 bg-blue-50 px-2 py-1 rounded">
{task.status}
</span>
</div>
))}
</React.Fragment>
))}
</div>
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="mt-4 px-4 py-2 bg-gray-100 rounded text-sm hover:bg-gray-200 disabled:opacity-50"
>
{isFetchingNextPage ? 'Đang tải thêm...' : 'Tải thêm dữ liệu'}
</button>
)}
</div>
)
}
Tóm Kết
Việc phân rã cấu trúc TanStack Query v5 theo hướng Feature-driven Architecture giúp dự án giữ được tính modular cao. Khi một kỹ sư mới tham gia vào dự án, họ chỉ cần truy cập vào thư mục features/task/queries là có thể hiểu toàn bộ vòng đời, trạng thái, cấu trúc dữ liệu và các thao tác ghi dữ liệu của Feature đó mà không sợ làm ảnh hưởng tới các phân vùng logic khác.


