TanStack Query · Phần 3 — Query keys, zod & queryOptions
Thiết kế query key phân cấp chống gõ sai, viết apiFetch validate dữ liệu tại biên bằng zod để any không lọt vào app, gói query bằng queryOptions tái dùng, và làm dependent/parallel queries.
Hai phần trước ta fetch bằng queryKey: ['users'] và fetch().then(r => r.json()) cho nhanh. Trong dự án thật, cách đó nhanh chóng sinh bug: query key rải rác dễ gõ sai, và res.json() trả về any khiến TypeScript “tin server mù quáng”. Phần này dựng nền tảng chuẩn cho phần còn lại của series:
- Một hệ query key phân cấp chống gõ sai và giúp invalidation chính xác.
- Một
apiFetchdùng chung validate dữ liệu tại biên bằng zod. - Gói query bằng
queryOptionsđể tái dùng giữauseQuery, prefetch,setQueryData. - Dependent và parallel queries.
1. Query key — định danh của cache
Query key là danh tính (identity) của một mẩu dữ liệu trong cache. Mỗi key được băm thành một chuỗi ổn định; chuỗi đó là khoá trong Map nội bộ của QueryCache. Cùng key → cùng cache entry → chia sẻ data, status, observer. Khác key → entry mới hoàn toàn.
queryKey: ['customers','list',{ status:'active' }]
│
▼ hashKey() — JSON.stringify ổn định, SORT key của object
'["customers","list",{"status":"active"}]'
│
▼ dùng làm khoá
QueryCache Map<hash, Query>
├─ '["customers"]' → Query
├─ '["customers","list",{"status":"active"}]' → Query ← entry này
└─ '["customers","detail","42"]' → Query
Hai quy tắc vàng:
- Key phải mô tả đầy đủ mọi thứ mà
queryFnphụ thuộc.queryFndùnguserIdvàfilters→ cả hai phải nằm trong key. (Như “dependency array” củauseEffect, nhưng cho cache.) - Key dạng mảng, đi từ tổng quát → cụ thể:
['customers', 'list', { status: 'active' }]. Cấu trúc này mở khoá invalidation theo tiền tố (mục 3).
Băm key là xác định (deterministic)
React Query băm key bằng một bản JSON.stringify ổn định: nó sắp xếp key của object trước khi stringify. Hệ quả:
// Thứ tự field trong OBJECT không quan trọng → cùng hash → cùng entry
['todos', { status: 'done', page: 1 }]
['todos', { page: 1, status: 'done' }] // ← GIỐNG nhau
// Nhưng thứ tự phần tử trong MẢNG thì quan trọng → khác hash → khác entry
['todos', 'done', 1]
['todos', 1, 'done'] // ← KHÁC nhau
// Khác giá trị → khác key → khác cache entry
['todos', { status: 'active', page: 1 }]
['todos', { status: 'active', page: 2 }]
Vì sao object thì độc-lập-thứ-tự còn mảng thì không? Mảng mã hoá thứ bậc (tổng quát→cụ thể) nên thứ tự là ngữ nghĩa; object mã hoá tập tham số nên thứ tự field chỉ là ngẫu nhiên.
hashKeytôn trọng cả hai.
Vì key đã chứa mọi phụ thuộc, bạn không cần dependency array thủ công: đổi tham số trong key ⇒ Query tự fetch lại với một cache entry riêng cho tham số mới (entry cũ vẫn còn). Đây cũng là lý do Query miễn nhiễm race condition (Phần 1).
Cái gì được phép nằm trong key?
Key phải serializable và ổn định giữa các lần render. Bảng tra:
| Loại giá trị | Hợp lệ trong key? | Ghi chú |
|---|---|---|
string, number, boolean | ✅ | Nguyên thuỷ, băm ổn định |
null | ✅ | Băm thành null |
| Mảng các giá trị trên | ✅ | Thứ tự quan trọng |
| Object phẳng (plain) | ✅ | Thứ tự field không quan trọng |
| Object lồng object/mảng | ✅ | Miễn mọi lá đều serializable |
undefined trong object | ⚠️ | Bị bỏ qua khi stringify → dễ nhầm; tránh |
Date, Map, Set, instance class | ❌ | Không serialize ổn định → key “trôi” |
Hàm, Symbol, BigInt | ❌ | Không stringify được |
Quy tắc thực dụng: chỉ nhét nguyên thuỷ và plain object/array vào key. Cần
Date? Đưadate.toISOString(). CầnSet? Đưa[...set].sort().
2. Query key factory — chống gõ sai
Rải string literal khắp nơi (['customers'] chỗ này, ['customer', id] chỗ kia — số ít/số nhiều đã lệch!) là mầm bug: gõ sai một ký tự = âm thầm tạo cache thứ hai, hoặc invalidation trượt. Giải pháp: gom toàn bộ key của một feature vào một factory duy nhất.
// src/features/customers/keys.ts
export const customerKeys = {
all: ['customers'] as const,
lists: () => [...customerKeys.all, 'list'] as const,
list: (filters: { q?: string; status?: string }) =>
[...customerKeys.lists(), filters] as const,
details: () => [...customerKeys.all, 'detail'] as const,
detail: (id: string) => [...customerKeys.details(), id] as const,
};
as const giữ key là tuple readonly để TypeScript suy đúng literal type (không bị “mở rộng” thành string[]). Vì mỗi tầng trải (...) tầng trên, các key tạo thành một cây có tiền tố chung:
all ['customers']
├─ lists() ['customers','list']
│ └─ list(f) ['customers','list', { …filters }]
└─ details() ['customers','detail']
└─ detail(id) ['customers','detail', id]
Bảng tra factory → key array:
| Gọi | Key sinh ra | Dùng khi |
|---|---|---|
customerKeys.all | ['customers'] | Invalidate mọi thứ liên quan customer |
customerKeys.lists() | ['customers','list'] | Invalidate mọi danh sách (mọi filter) |
customerKeys.list({ q }) | ['customers','list',{ q }] | Một danh sách cụ thể |
customerKeys.details() | ['customers','detail'] | Invalidate mọi chi tiết |
customerKeys.detail('42') | ['customers','detail','42'] | Một chi tiết cụ thể |
Vì all là tiền tố của mọi key khác, invalidation có thể chính xác hoặc lan rộng tuỳ tầng bạn chọn:
queryClient.invalidateQueries({ queryKey: customerKeys.detail('42') }); // chỉ 1 chi tiết
queryClient.invalidateQueries({ queryKey: customerKeys.lists() }); // mọi danh sách
queryClient.invalidateQueries({ queryKey: customerKeys.all }); // tất cả customer
Đặt factory cạnh schema và queryOptions của feature (
src/features/customers/). Một feature = một file keys. Đừng có hai nơi cùng định nghĩa key cho customer.
3. Khớp tiền tố (fuzzy matching) — vì sao factory đáng giá
invalidateQueries({ queryKey }) không đòi khớp tuyệt đối. Mặc định nó khớp theo tiền tố: mọi query có key bắt đầu bằng mảng bạn đưa đều bị nhắm tới. Đây là lý do thiết kế key phân cấp quan trọng đến vậy.
Giả sử cache đang có các entry:
A ['customers']
B ['customers','list',{ status:'active' }]
C ['customers','list',{ status:'churned' }]
D ['customers','detail','42']
E ['customers','detail','99']
F ['invoices','list']
Bảng: filter nào khớp entry nào?
Filter queryKey | Khớp | Không khớp |
|---|---|---|
['customers'] | A, B, C, D, E | F |
['customers','list'] | B, C | A, D, E, F |
['customers','list',{ status:'active' }] | B | còn lại |
['customers','detail'] | D, E | còn lại |
['customers','detail','42'] | D | còn lại |
['invoices'] | F | còn lại |
Hai công cụ tinh chỉnh độ rộng:
// exact: chỉ khớp ĐÚNG key này (tắt khớp tiền tố)
queryClient.invalidateQueries({ queryKey: customerKeys.all, exact: true }); // chỉ A
// predicate: lọc tuỳ ý khi key không đủ diễn đạt điều kiện
queryClient.invalidateQueries({
predicate: (q) => {
const [scope, kind, filters] = q.queryKey;
return (
scope === 'customers' &&
kind === 'list' &&
typeof filters === 'object' &&
filters !== null &&
'status' in filters &&
filters.status === 'churned' // → chỉ C
);
},
});
Cùng cơ chế khớp này áp cho
removeQueries,cancelQueries,refetchQueries,getQueriesData,setQueriesData. Học một lần, dùng khắp nơi. Chi tiết invalidation thực chiến ở Phần 5–6.
4. Validate tại biên với zod
Tài liệu: zod.
Vấn đề
res.json() trả Promise<any>. TypeScript tin server vô điều kiện. Nếu API đổi tên field, hay trả về null bất ngờ, bạn không phát hiện ở biên mà ở tận sâu trong component — một crash khó truy. Quy tắc của project (xem prohibitions): không any, không as trừ khi đã validate runtime.
Giải pháp: apiFetch + zod
// src/lib/api-client.ts
import { z } from 'zod';
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
/**
* Fetch + validate tại biên. Đọc `unknown` rồi parse bằng schema,
* nên `any` không bao giờ lọt vào trong app.
*/
export async function apiFetch<T>(
path: string,
schema: z.ZodType<T>,
init?: RequestInit,
): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
});
if (!res.ok) {
throw new ApiError(res.status, `Request lỗi: ${res.status}`);
}
const json: unknown = await res.json();
// Kiểm tra hình dạng dữ liệu TRƯỚC khi nó vào app — fail to, fail sớm.
return schema.parse(json);
}
Định nghĩa schema theo feature và suy type từ schema — một nguồn chân lý cho cả runtime lẫn compile time:
// src/features/customers/schema.ts
import { z } from 'zod';
export const customerSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
plan: z.enum(['starter', 'pro', 'enterprise']),
mrr: z.number(),
status: z.enum(['active', 'trial', 'churned']),
});
export const customerListSchema = z.array(customerSchema);
// Suy type TỪ schema → validator và type không bao giờ lệch nhau.
export type Customer = z.infer<typeof customerSchema>;
z.infer lấy type Customer từ schema, nên khi schema đổi, type tự đổi theo. Bạn không bao giờ phải viết một interface Customer song song rồi quên cập nhật.
as nói dối, parse kiểm thật
data as Customer[] | customerListSchema.parse(data) | |
|---|---|---|
| Khi nào kiểm | Không bao giờ (chỉ compile-time) | Runtime, tại biên |
| API đổi field | Im lặng → crash ở chỗ khác | Ném ZodError ngay, đúng chỗ |
null/thiếu field | Lọt qua → cannot read x of null | Bắt ngay với đường dẫn field |
| Nguồn type | Tay viết, dễ lệch | z.infer — luôn khớp validator |
| An toàn | ❌ “tin tưởng mù” | ✅ “tin nhưng có kiểm” |
Khi muốn xử lý lỗi mềm (không ném), dùng safeParse:
const parsed = customerListSchema.safeParse(json);
if (!parsed.success) {
// parsed.error.issues: danh sách field sai + đường dẫn
throw new ApiError(422, 'Dữ liệu API không hợp lệ');
}
return parsed.data; // đã có type Customer[]
Đây là chỗ duy nhất nên có “ép kiểu”: ngay sau
parse/safeParse, type đã được runtime bảo chứng. Mọi chỗ khác cấmas— vì lúc đó nó chỉ là lời hứa suông.
5. Gói query bằng queryOptions
Tài liệu: queryOptions.
Thay vì rải { queryKey, queryFn } khắp nơi, gói chúng vào một helper queryOptions. Lợi ích: một định nghĩa, tái dùng giữa useQuery, useQueries, prefetchQuery, setQueryData… và type liên kết chặt giữa key và data.
// src/features/customers/api.ts
import { queryOptions } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api-client';
import { customerListSchema, customerSchema } from './schema';
import { customerKeys } from './keys';
export function customersQuery(filters: { q?: string } = {}) {
return queryOptions({
queryKey: customerKeys.list(filters),
queryFn: () => {
// Dựng query string thủ công — KHÔNG `as`, đúng tinh thần "không ép kiểu thiếu kiểm".
const params = new URLSearchParams();
if (filters.q) params.set('q', filters.q);
return apiFetch(`/customers?${params}`, customerListSchema);
},
});
}
export function customerQuery(id: string) {
return queryOptions({
queryKey: customerKeys.detail(id),
queryFn: () => apiFetch(`/customers/${id}`, customerSchema),
});
}
Hook của feature trở nên mỏng dính:
// src/features/customers/hooks.ts
import { useQuery } from '@tanstack/react-query';
import { customersQuery, customerQuery } from './api';
export function useCustomers(filters: { q?: string } = {}) {
return useQuery(customersQuery(filters));
}
export function useCustomer(id: string) {
return useQuery(customerQuery(id));
}
Và component chỉ còn lo render:
function CustomersTable() {
const { data, isPending, isError, error } = useCustomers();
if (isPending) return <TableSkeleton />;
if (isError) return <ErrorState message={error.message} />;
if (data.length === 0) return <EmptyState label="Chưa có khách hàng" />;
return <DataTable rows={data} />; // data có type Customer[] đầy đủ
}
Một định nghĩa, nhiều nơi dùng
customerQuery(id) giờ là một “viên gạch” tái dùng. Cùng object đó cắm vào mọi API của Query — và type (key ⇄ data) luôn khớp:
| API | Cách dùng lại | Được gì |
|---|---|---|
useQuery | useQuery(customerQuery(id)) | Đọc trong component |
useQueries | queries: ids.map(customerQuery) | Danh sách song song (mục 8) |
prefetchQuery | qc.prefetchQuery(customerQuery(id)) | Nạp trước khi điều hướng |
ensureQueryData | await qc.ensureQueryData(customerQuery(id)) | Lấy data (cache hoặc fetch) trong loader |
setQueryData | qc.setQueryData(customerQuery(id).queryKey, next) | Ghi cache với key đúng kiểu |
getQueryData | qc.getQueryData(customerQuery(id).queryKey) | Đọc cache, suy ra Customer | undefined |
// Prefetch khi hover link — không lặp lại key/queryFn/schema
function onHover(id: string) {
queryClient.prefetchQuery(customerQuery(id));
}
// setQueryData lấy key TỪ chính options → không thể lệch key
queryClient.setQueryData(customerQuery('42').queryKey, (old) => old);
Cái win: trước đây key sống ở component, queryFn ở chỗ khác, type ở file thứ ba — ba nguồn dễ lệch.
queryOptionsgộp cả ba thành một giá trị có type liên kết: sai key là sai type, IDE bắt ngay.
Quy ước của project: tách logic khỏi UI. Key/schema/queryFn nằm ở
lib/feature, component chỉ gọi hook và render trạng thái. Đây cũng là điều giúp test dễ (Phần 8).
6. Dependent queries — query phụ thuộc query khác
Đôi khi query B cần kết quả của query A. Ví dụ: lấy user trước, rồi mới lấy projects của user đó. Dùng option enabled để hoãn query B cho tới khi có dữ liệu:
function UserProjects({ email }: { email: string }) {
// Query 1: lấy user theo email
const userQ = useQuery({
queryKey: ['user', email],
queryFn: () => apiFetch(`/users?email=${email}`, userSchema),
});
const userId = userQ.data?.id;
// Query 2: chỉ chạy khi đã có userId
const projectsQ = useQuery({
queryKey: ['projects', userId],
queryFn: () => apiFetch(`/users/${userId}/projects`, projectListSchema),
enabled: Boolean(userId), // ← hoãn cho tới khi userId tồn tại
});
// Khi enabled=false, query ở trạng thái pending nhưng fetchStatus='idle'
if (projectsQ.isPending) return <Spinner />;
// ...
}
Khi enabled: false, query không chạy queryFn; nó nằm ở status: 'pending', fetchStatus: 'idle'. Nhớ điều này để render đúng (đừng tưởng nhầm là đang loading mạng).
enabled | status | fetchStatus | Ý nghĩa render |
|---|---|---|---|
false | 'pending' | 'idle' | Đang chờ điều kiện — đừng hiện spinner mạng |
true, lần đầu | 'pending' | 'fetching' | Loading thật — hiện skeleton |
true, có data | 'success' | 'idle' | Render data |
Mẹo phân biệt: dùng
isLoading(=isPending && isFetching) khi muốn hỏi “có đang tải mạng lần đầu không”;isPendingđơn thuần chỉ nói “chưa có data” — đúng cả khi đang bịenabled:falsechặn.
7. Parallel queries — chạy song song, tránh waterfall
“Waterfall” là sát thủ hiệu năng: component A fetch xong mới render B, B rồi mới fetch của nó — các round-trip nối đuôi nhau. Nếu các query độc lập, hãy bắn song song.
Hai useQuery trong cùng component đã tự chạy song song:
function Dashboard() {
const stats = useQuery(statsQuery()); // hai cái này
const activity = useQuery(recentActivityQuery()); // chạy SONG SONG
// ...
}
Với một danh sách động các query, dùng useQueries:
import { useQueries } from '@tanstack/react-query';
function CustomerCards({ ids }: { ids: string[] }) {
const results = useQueries({
queries: ids.map((id) => customerQuery(id)), // tái dùng queryOptions ở mục 4
});
const isLoading = results.some((r) => r.isPending);
// ...
}
Phản mẫu cần tránh: render cha (fetch), và chỉ trong nhánh thành công của cha mới mount con (fetch). Điều đó nối tiếp hai request độc lập. Hãy đưa các query độc lập lên cùng cấp để chúng chồng lên nhau.
WATERFALL (xấu) SONG SONG (tốt)
A ───▶ done A ───▶ done
B ───▶ done B ───▶ done (chồng lên A)
(B chờ A vô cớ) tổng thời gian ≈ max(A,B)
tổng ≈ A + B
| Tình huống | Dùng | Vì sao |
|---|---|---|
| Vài query cố định, độc lập | nhiều useQuery anh em | Tự song song, code phẳng |
| Query B cần data của A | useQuery + enabled (mục 6) | Buộc phải tuần tự |
| Số lượng query động (theo mảng) | useQueries | Không vi phạm Rules of Hooks |
| Cần gộp kết quả nhiều query | useQueries({ combine }) | Trả về một giá trị đã gộp, ổn định |
8. Gotchas thường gặp
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
| Đổi filter/trang nhưng data không đổi | Key thiếu phụ thuộc (['orders'] thay vì ['orders', userId, page]) | Đưa đủ biến vào key |
| Hai cache “ma” cho cùng dữ liệu | Gõ sai literal ('customer' vs 'customers') | Dùng key factory, cấm string rời |
| Key “trôi” mỗi render, query refetch loạn | Nhét new Date()/{}/hàm vào key | Chỉ dùng nguyên thuỷ + plain object; serialize Date |
| Invalidate một thứ nhưng mọi thứ refetch | Filter quá rộng (all thay vì lists()) | Chọn đúng tầng tiền tố, hoặc exact: true |
data vẫn là any, mất gợi ý IDE | queryFn trả res.json() chưa parse | Validate bằng zod, suy type qua z.infer |
setQueryData không cập nhật UI | Ghi sai key (lệch object/thứ tự mảng) | Lấy key từ queryOptions(...).queryKey |
enabled:false hiện spinner mãi | Hiểu nhầm isPending = đang tải | Phân biệt status vs fetchStatus (mục 6) |
9. Recipes nhanh
“Public API” gọn gàng của một feature (keys + schema + options + hooks ở cùng chỗ):
// src/features/customers/index.ts
export { customerKeys } from './keys';
export { customerSchema, customerListSchema, type Customer } from './schema';
export { customersQuery, customerQuery } from './api';
export { useCustomers, useCustomer } from './hooks';
Invalidate sau khi sửa một customer (đón đầu Phần 5):
function useUpdateCustomer() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: Customer) =>
apiFetch(`/customers/${input.id}`, customerSchema, {
method: 'PUT',
body: JSON.stringify(input),
}),
onSuccess: (saved) => {
qc.setQueryData(customerKeys.detail(saved.id), saved); // cập nhật chi tiết
qc.invalidateQueries({ queryKey: customerKeys.lists() }); // làm tươi danh sách
},
});
}
Gộp nhiều query thành một giá trị với useQueries({ combine }):
function CustomerMrr({ ids }: { ids: string[] }) {
const totalMrr = useQueries({
queries: ids.map((id) => customerQuery(id)),
combine: (results) =>
results.reduce((sum, r) => sum + (r.data?.mrr ?? 0), 0),
});
return <strong>{totalMrr}</strong>;
}
10. Bài tập
1. Vì sao nên validate res.json() bằng zod thay vì ép kiểu as Customer[]?
Lời giải
as là “lời nói dối lúc compile” — bảo TypeScript tin dữ liệu runtime chưa kiểm. Nếu API đổi/lỗi, type sai âm thầm và crash ở chỗ khác. schema.parse kiểm hình dạng thật tại biên và ném lỗi ngay khi lệch, biến lỗi runtime mơ hồ thành lỗi rõ ràng đúng chỗ.
2. Một query có queryFn dùng userId và page, nhưng queryKey chỉ là ['orders']. Bug gì sẽ xảy ra?
Lời giải
Mọi userId/page chia sẻ cùng một cache entry → đổi trang hay đổi user vẫn trả data cũ (cache hit sai), và refetch ghi đè lẫn nhau. Key phải chứa đủ phụ thuộc: ['orders', userId, page].
3. Khi enabled: false, status và fetchStatus của query là gì?
Lời giải
status: 'pending' (chưa có data) nhưng fetchStatus: 'idle' (không gọi mạng). Đừng hiển thị spinner “đang tải mạng” cho trạng thái này; nên hiện placeholder kiểu “đang chờ điều kiện”.
4. Cache có các key ['customers'], ['customers','list',{status:'active'}], ['customers','detail','42']. Gọi invalidateQueries({ queryKey: ['customers','list'] }) sẽ làm tươi những entry nào?
Lời giải
Chỉ ['customers','list',{status:'active'}]. Khớp theo tiền tố: entry phải bắt đầu bằng ['customers','list']. ['customers'] ngắn hơn (không chứa đủ tiền tố), còn ['customers','detail','42'] rẽ nhánh detail nên đều trượt.
5. ['t', { a: 1, b: 2 }] và ['t', { b: 2, a: 1 }] có cùng cache entry không? Còn ['t', 1, 2] với ['t', 2, 1]?
Lời giải
Cặp object: cùng entry — hashKey sort field object trước khi stringify nên thứ tự field không đổi danh tính. Cặp mảng: khác entry — thứ tự phần tử mảng là ngữ nghĩa (tổng quát→cụ thể) nên được giữ nguyên khi băm.
Nâng cao: Viết customerKeys cho feature của bạn, một apiFetch + zod schema, và queryOptions cho cả list lẫn detail. Thêm hook useCustomer(id) rồi render ở trang chi tiết.
Tóm tắt
- Query key là danh tính cache: được băm xác định (object không phụ thuộc thứ tự field, mảng thì có). Key phải chứa đủ mọi phụ thuộc của
queryFn, và chỉ chứa giá trị serializable. - Dùng key factory mỗi feature (
all/lists/list/details/detail) để chống gõ sai và mở khoá invalidation phân cấp. invalidateQuerieskhớp theo tiền tố — chọn đúng tầng để nhắm hẹp hay rộng;exact: truehoặcpredicatekhi cần tinh chỉnh.- Validate mọi response bằng zod tại biên trong
apiFetch, suy type quaz.infer—anykhông lọt vào app, vàaschỉ hợp lệ ngay sauparse. - Gói query bằng
queryOptionsđể tái dùng giữauseQuery/useQueries/prefetchQuery/ensureQueryData/setQueryData, với key ⇄ data type liên kết. - Dependent query dùng
enabled(phân biệtstatusvsfetchStatus); parallel query dùnguseQueryanh em hoặcuseQueriesđể tránh waterfall.
Phần tiếp theo
Phần 4 — Pagination & useInfiniteQuery: giữ trang cũ mượt mà khi chuyển trang với placeholderData: keepPreviousData, rồi dựng infinite scroll thực thụ bằng useInfiniteQuery với getNextPageParam/fetchNextPage và một IntersectionObserver.