TanStack Query · Phần 9 — QueryClient & Defaults sâu
Cấu hình QueryClient ở mức senior: defaultOptions cho queries/mutations, QueryCache & MutationCache với onError/onSuccess toàn cục, networkMode, structuralSharing, và setQueryDefaults theo từng nhóm key.
Tám phần đầu đưa bạn từ mental model tới một feature CRUD có test. Mười phần tiếp theo là phần chuyên sâu — những thứ tách một app “chạy được” khỏi một codebase mà cả team senior duyệt mà gật đầu. Mở màn là thứ bạn đã chạm từ Phần 1 nhưng chưa đào tận đáy: QueryClient.
QueryClient không chỉ là “nơi chứa cache”. Nó là điểm cấu hình tập trung cho retry, refetch, garbage collection, xử lý lỗi toàn cục, và hành vi offline. Cấu hình đúng một lần ở đây giúp bạn xoá hàng trăm dòng lặp ở từng useQuery.
Phiên bản dùng trong series: React 19 + @tanstack/react-query v5 + TypeScript strict. Không
any, khôngas(trừ sau khi validate runtime bằng zod).
Giải phẫu một QueryClient — nhớ sơ đồ này, cả phần còn lại chỉ là phóng to từng nhánh:
QueryClient
├── defaultOptions
│ ├── queries → mặc định cho mọi useQuery / useInfiniteQuery (mục 2)
│ └── mutations → mặc định cho mọi useMutation (mục 3)
├── QueryCache → kho query + hook sự kiện toàn cục onError/… (mục 5)
├── MutationCache → kho mutation + hook sự kiện toàn cục onError/… (mục 6)
└── methods → invalidate / setQueryData / prefetch / … (mục 10)
1. Ba lớp cấu hình & thứ tự ưu tiên
Mỗi option (vd staleTime, retry, gcTime) có thể đặt ở ba nơi. Khi cùng một option xuất hiện nhiều nơi, TanStack Query gộp theo nguyên tắc hook thắng per-key, per-key thắng global:
Độ ưu tiên (cao → thấp) — cái cao GHI ĐÈ cái thấp:
┌──────────────────────────────────────────────────┐
│ 1. Hook-level │ useQuery({ staleTime })
│ options trực tiếp trong useQuery / useMutation │
├──────────────────────────────────────────────────┤
│ 2. Per-key defaults │ setQueryDefaults(key, …)
│ setQueryDefaults / setMutationDefaults │ (khớp theo prefix key)
├──────────────────────────────────────────────────┤
│ 3. Global defaults │ new QueryClient({ defaultOptions })
│ defaultOptions.queries / .mutations │
└──────────────────────────────────────────────────┘
Bảng tra nhanh:
| Mức | Đặt ở đâu | Phạm vi | Khi nào dùng |
|---|---|---|---|
| Hook-level | Trong chính useQuery / useMutation | Một query/mutation | Ngoại lệ có chủ đích |
| Per-key | setQueryDefaults(key, …) / setMutationDefaults(key, …) | Mọi key khớp prefix | Một nhóm data cùng đặc tính |
| Global | defaultOptions.queries / .mutations | Toàn app | Mặc định an toàn cho số đông |
Quy tắc tư duy: đặt mặc định hợp lý ở global, tinh chỉnh theo nhóm ở per-key, chỉ chạm hook-level khi thật sự là ngoại lệ. Đừng copy
staleTimevào 50 query — sửa một chỗ.
Một điểm dễ nhầm: việc gộp là theo từng option, không phải “lấy nguyên cụm”. Nếu global đặt cả staleTime lẫn retry, còn hook chỉ đặt staleTime, thì retry vẫn lấy từ global — chứ không bị “xoá” vì hook không nhắc tới.
2. defaultOptions.queries — bảng tra đầy đủ
Đây là những option bạn đặt một lần ở client-level và áp cho mọi query. Bảng dưới liệt kê các option hay dùng nhất, kèm mặc định của thư viện và gợi ý production:
| Option | Kiểu | Mặc định v5 | Gợi ý production & vì sao |
|---|---|---|---|
staleTime | number | (query) => number | 0 | 60_000. Mặc định 0 khiến data stale ngay, gây refetch thừa mỗi mount/focus. |
gcTime | number | 300_000 (5 phút) | Giữ nguyên hoặc nâng cho data nặng; đây là trục “tuổi thọ cache” (mục 9). |
retry | boolean | number | (failureCount, error) => boolean | 3 | Hàm bỏ qua 4xx, thử lại tối đa 2 lần cho lỗi mạng/5xx. |
retryDelay | number | (attempt, error) => number | exp backoff | Backoff luỹ thừa có trần (30_000). |
refetchOnWindowFocus | boolean | 'always' | fn | true | true cho data “sống”; tắt cho form đang nhập dở. |
refetchOnMount | boolean | 'always' | fn | true | true (chỉ refetch khi stale); 'always' ép refetch mọi mount. |
refetchOnReconnect | boolean | 'always' | fn | true | Giữ true để đồng bộ sau khi mạng trở lại. |
refetchInterval | number | false | fn | false | Polling; nên đặt per-key cho data realtime. |
networkMode | 'online' | 'always' | 'offlineFirst' | 'online' | Quyết định hành vi offline (mục 4). |
structuralSharing | boolean | fn | true | Giữ true: nội dung không đổi → tham chiếu không đổi → bớt re-render. |
placeholderData | T | (prev) => T | — | keepPreviousData cho phân trang (Phần 4). |
select | (data) => U | — | Thường đặt hook-level, không global. |
enabled | boolean | true | Đặt hook-level (dependent query), không global. |
throwOnError | boolean | fn | false | true khi dùng Error Boundary (Phần 7). |
// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';
import { ApiError } from './api-error';
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // cắt refetch thừa, vẫn đủ tươi cho dashboard
gcTime: 5 * 60_000, // back/forward thấy data ngay
retry: (failureCount, error) => {
// Bỏ qua 4xx (client gửi sai → thử lại vô ích)
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
return false;
}
return failureCount < 2; // mạng/5xx: thử tối đa 2 lần
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
mutations: {
retry: 0, // xem mục 3
},
},
});
}
Vì sao bọc trong một factory
makeQueryClient()thay vì một instance module-scope? Vì SSR (Phần 10) và test cần một client mới mỗi lần. Mục 11 hoàn thiện pattern này.
3. defaultOptions.mutations — bảng tra đầy đủ
Mutation có ít option hơn query nhưng cũng có hook sự kiện riêng:
| Option | Kiểu | Mặc định | Gợi ý production & vì sao |
|---|---|---|---|
retry | boolean | number | fn | 0 | Giữ 0: thử lại mutation dễ tạo bản ghi trùng (POST hai lần). |
retryDelay | number | fn | exp backoff | Chỉ có ý nghĩa nếu đã bật retry. |
networkMode | 'online' | 'always' | 'offlineFirst' | 'online' | 'offlineFirst' cho hàng đợi offline (Phần 15). |
gcTime | number | 300_000 | Bao lâu giữ trạng thái mutation sau khi xong. |
onMutate | (vars) => ctx | — | Đặt hook-level cho optimistic update (Phần 6). |
onError / onSuccess / onSettled | callback | — | Global ở MutationCache (mục 6) + local ở hook. |
meta | Record<string, unknown> | — | Khai báo “đụng key nào” cho invalidation tập trung (mục 7). |
mutations: {
retry: 0, // an toàn mặc định: không tự lặp lại thao tác ghi
networkMode: 'online',
}
Mặc định mutation KHÔNG retry là có chủ đích. Một POST tạo đơn hàng mà tự thử lại khi timeout có thể tạo hai đơn. Nếu endpoint idempotent (PUT/DELETE, hoặc có idempotency key), bật retry per-key bằng
setMutationDefaults(mục 8).
4. networkMode — hành vi khi mất mạng
networkMode quyết định query/mutation cư xử thế nào khi trình duyệt offline. Đây là option dễ bị bỏ quên nhưng đổi hẳn UX offline-first (Phần 13 dùng lại).
| Giá trị | Hành vi khi offline |
|---|---|
'online' (mặc định) | Query không chạy, chuyển sang trạng thái paused (fetchStatus: 'paused'). Tự chạy lại khi có mạng. |
'always' | Luôn chạy queryFn, kể cả offline. Hợp khi queryFn đọc từ cache/AsyncStorage chứ không gọi mạng. |
'offlineFirst' | Chạy một lần; nếu lỗi thì pause. Hợp với service worker / HTTP cache đứng trước. |
new QueryClient({
defaultOptions: {
queries: { networkMode: 'offlineFirst' }, // tận dụng cache của SW trước
},
});
Phân biệt ba trạng thái khi đọc fetchStatus: 'fetching' (đang gọi), 'paused' (muốn gọi nhưng offline), 'idle' (không làm gì). UI nên hiện badge “đang chờ mạng” khi paused.
5. QueryCache — xử lý lỗi & sự kiện toàn cục
QueryClient chứa một QueryCache. Bạn gắn handler toàn cục vào đây để log lỗi, bắn toast, hoặc đăng xuất khi 401 — thay vì lặp ở mọi query.
| Hook | Khi nào chạy | Dùng để |
|---|---|---|
onError(error, query) | Mỗi khi một query lỗi | Log Sentry, toast refetch nền lỗi, logout 401 |
onSuccess(data, query) | Mỗi khi một query thành công | Hiếm dùng; analytics / đo lường |
onSettled(data, error, query) | Sau mỗi query (thành công hoặc lỗi) | Dọn dẹp, đo latency |
import { QueryClient, QueryCache } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ApiError } from './api-error';
export const queryCache = new QueryCache({
onError: (error, query) => {
// 401 toàn cục → đăng xuất. Một chỗ duy nhất, không lặp.
if (error instanceof ApiError && error.status === 401) {
redirectToLogin();
return;
}
// Chỉ toast khi query ĐÃ từng có data (refetch nền thất bại):
// lỗi lần đầu đã render thành error state, toast nữa là thừa.
if (query.state.data !== undefined) {
toast.error(`Làm mới thất bại: ${error.message}`);
}
},
});
ApiErrorlà lớp lỗi tự định nghĩa từ Phần 3 (bọcstatusHTTP). Bản tối thiểu:// src/lib/api-error.ts export class ApiError extends Error { constructor(message: string, readonly status: number) { super(message); this.name = 'ApiError'; } }
Vì sao kiểm tra query.state.data !== undefined? Lỗi lần đầu (chưa có data) thường đã render thành error state trong component; toast thêm là thừa. Lỗi khi refetch nền thì component vẫn hiện data cũ, nên toast là cách duy nhất báo cho user biết.
6. MutationCache — global error→toast & invalidation tập trung
MutationCache cũng có onError/onSuccess/onSettled, nhưng nhận thêm tham số mutation (chứa meta). Hai pattern đắt giá:
(a) Global error → toast — gom mọi lỗi mutation về một handler, hết lặp onError ở từng hook:
import { MutationCache } from '@tanstack/react-query';
import { toast } from 'sonner';
export const mutationCache = new MutationCache({
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Thao tác thất bại');
},
});
Thứ tự gọi:
MutationCache.onErrorchạy trướconErrorcủa từnguseMutation. Dùng global cho việc chung (toast, log); local cho rollback đặc thù (Phần 6).
(b) Invalidation theo meta — mỗi mutation khai báo “tôi đụng key nào”, cache invalidate tập trung:
const mutationCache = new MutationCache({
onSuccess: (_data, _vars, _ctx, mutation) => {
const keys = mutation.meta?.invalidates;
if (keys) {
keys.forEach((key) => queryClient.invalidateQueries({ queryKey: key }));
}
},
});
// Dùng tại hook — chỉ KHAI BÁO, không gọi invalidate bằng tay:
useMutation({
mutationFn: createCustomer,
meta: { invalidates: [customerKeys.lists()] },
});
Cách này gom logic “ghi xong thì làm mới gì” về một chỗ. Nhưng để mutation.meta?.invalidates không bị any, ta phải khai báo kiểu cho meta — mục 7.
7. meta có kiểu — global handler type-safe
Mặc định meta là Record<string, unknown>, nên mutation.meta?.invalidates không được gợi ý kiểu. Dùng module augmentation trên interface Register để khai báo kiểu cho cả query meta và mutation meta:
import '@tanstack/react-query';
declare module '@tanstack/react-query' {
interface Register {
queryMeta: {
/** Tắt toast lỗi toàn cục cho query này */
readonly suppressGlobalError?: boolean;
};
mutationMeta: {
/** Key cần invalidate sau khi mutation thành công */
readonly invalidates?: ReadonlyArray<ReadonlyArray<unknown>>;
/** Thông điệp toast thành công (nếu có) */
readonly successMessage?: string;
};
}
}
Giờ global handler đọc meta có kiểu, không any:
const queryCache = new QueryCache({
onError: (error, query) => {
if (query.meta?.suppressGlobalError) return; // ← được gợi ý kiểu đầy đủ
toast.error(`Làm mới thất bại: ${error.message}`);
},
});
const mutationCache = new MutationCache({
onSuccess: (_data, _vars, _ctx, mutation) => {
mutation.meta?.invalidates?.forEach((key) =>
queryClient.invalidateQueries({ queryKey: key }),
);
if (mutation.meta?.successMessage) toast.success(mutation.meta.successMessage);
},
});
Đây là một lát cắt của kỹ thuật augmentation; Phần 17 đào sâu cách dùng
Registercho cảdefaultErrorvà typing của query key.
8. setQueryDefaults & setMutationDefaults — mặc định theo nhóm key
Không phải mọi query cần cùng staleTime. Thay vì ghi đè ở từng query, đặt mặc định theo prefix key:
// Data ít đổi (danh mục, cấu hình) → fresh "mãi"
queryClient.setQueryDefaults(['config'], { staleTime: Infinity });
// Data realtime (giá, thông báo) → luôn stale, refetch tích cực
queryClient.setQueryDefaults(['ticker'], { staleTime: 0, refetchInterval: 5_000 });
// Mọi query bắt đầu bằng ['customers', …] → 2 phút
queryClient.setQueryDefaults(['customers'], { staleTime: 2 * 60_000 });
Khớp theo prefix: ['customers'] áp cho cả ['customers', 'list'] lẫn ['customers', 'detail', 5]. Đây là nơi hệ query key phân cấp (Phần 3) phát huy: thiết kế key tốt → cấu hình theo nhóm gọn gàng.
| Hàm | Tham số | Khớp theo | Bị ghi đè bởi |
|---|---|---|---|
setQueryDefaults(key, opts) | partial query options | prefix của queryKey | hook-level options |
setMutationDefaults(key, opts) | partial mutation options | prefix của mutationKey | hook-level options |
setMutationDefaults thường dùng để bật retry an toàn cho một nhóm mutation idempotent, hoặc gắn mutationFn mặc định cho offline queue (Phần 15):
queryClient.setMutationDefaults(['todos', 'update'], {
retry: 3, // PUT idempotent → retry an toàn
networkMode: 'offlineFirst',
});
Bẫy prefix lồng nhau: nếu cả
['customers']và['customers', 'detail']đều có defaults, key['customers', 'detail', 5]chỉ nhận một bộ — bộ khớp cụ thể nhất (prefix dài nhất) thắng, không gộp cả hai.
9. staleTime vs gcTime — đừng nhầm hai trục
Hai bộ đếm độc lập, hay bị lẫn:
staleTime— bao lâu data còn được coi là tươi (không tự refetch). Trục “độ mới”.gcTime— bao lâu cache entry còn nằm trong RAM sau khi không còn component nào dùng. Trục “tuổi thọ”.
staleTime = 60s, gcTime = 5min
t=0 fetch xong, data FRESH
t=60s data thành STALE (lần focus/mount sau sẽ refetch)
t=... component unmount → entry thành "inactive"
t=...+5min không ai dùng lại → entry bị xoá khỏi cache
Nhầm phổ biến: đặt staleTime: Infinity để “cache mãi”, nhưng gcTime mặc định 5 phút vẫn xoá entry khi không ai dùng. Muốn giữ thật lâu, nâng cả hai. Ngược lại, gcTime ngắn mà staleTime dài thì data biến mất sớm hơn bạn tưởng.
10. Bảng tra phương thức queryClient
QueryClient là cả một API mệnh lệnh để thao tác cache ngoài hook (event handler, loader của router, test). Bảng đầy đủ:
| Phương thức | Trả về | Mục đích |
|---|---|---|
invalidateQueries(filters) | Promise<void> | Đánh dấu stale + refetch các query active khớp filter |
refetchQueries(filters) | Promise<void> | Refetch ngay, bất kể stale hay không |
cancelQueries(filters) | Promise<void> | Huỷ request đang bay (trước optimistic update, Phần 6) |
prefetchQuery(options) | Promise<void> | Nạp trước, không trả data (làm ấm cache) |
fetchQuery(options) | Promise<T> | Nạp & trả data; throw nếu lỗi (dùng cho loader) |
ensureQueryData(options) | Promise<T> | Trả cache nếu có, nếu không thì fetch (idempotent) |
getQueryData(key) | T | undefined | Đọc data trong cache đồng bộ |
setQueryData(key, updater) | T | undefined | Ghi data thẳng vào cache (optimistic, Phần 6) |
getQueryState(key) | QueryState | undefined | Đọc cả status, dataUpdatedAt, error… |
getQueriesData(filters) | Array<[key, data]> | Đọc nhiều entry cùng lúc |
setQueriesData(filters, updater) | Array<[key, data]> | Ghi nhiều entry cùng lúc |
removeQueries(filters) | void | Xoá entry khỏi cache (không refetch) |
resetQueries(filters) | Promise<void> | Đưa query về trạng thái khởi tạo |
clear() | void | Xoá sạch mọi cache (vd khi logout) |
Phân biệt ba “anh em” hay lẫn:
prefetchQuery(nạp, nuốt lỗi, không trả data) ·fetchQuery(nạp, trả data, throw lỗi) ·ensureQueryData(trả cache nếu có, fetch nếu thiếu). Loader của router thường dùngensureQueryData. Phần 11 & 12 khai thác sâu bảng này.
// Prefetch khi hover để mở chi tiết "tức thì"
function onHoverRow(id: number) {
queryClient.prefetchQuery({
queryKey: customerKeys.detail(id),
queryFn: () => fetchCustomer(id),
staleTime: 30_000,
});
}
// Đọc cache đồng bộ trong event handler
const cached = queryClient.getQueryData<Customer>(customerKeys.detail(5));
11. Recipe: factory QueryClient cho production & SSR
Ở app client-only, một QueryClient module-scope là đủ. Nhưng trên server (Phần 10), dùng chung một client giữa các request là lỗi bảo mật: data của user A rò sang user B. Gộp mọi mảnh ở trên vào một factory duy nhất:
// src/lib/query-client.ts
import { QueryClient, QueryCache, MutationCache, isServer } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ApiError } from './api-error';
export function makeQueryClient(): QueryClient {
// Khai báo trước để handler trong cache tham chiếu được client.
// Arrow function chỉ ĐỌC `queryClient` khi mutation chạy (sau khi đã khởi tạo) → an toàn.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (error instanceof ApiError && error.status === 401) return redirectToLogin();
if (query.meta?.suppressGlobalError) return;
if (query.state.data !== undefined) toast.error(`Làm mới thất bại: ${error.message}`);
},
}),
mutationCache: new MutationCache({
onError: (error) => toast.error(error instanceof Error ? error.message : 'Thao tác thất bại'),
onSuccess: (_data, _vars, _ctx, mutation) => {
mutation.meta?.invalidates?.forEach((key) =>
queryClient.invalidateQueries({ queryKey: key }),
);
},
}),
defaultOptions: {
queries: { staleTime: 60_000, gcTime: 5 * 60_000, retry: 2 },
mutations: { retry: 0 },
},
});
return queryClient;
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient(): QueryClient {
if (isServer) {
// Server: luôn tạo MỚI để không chia sẻ cache giữa request.
return makeQueryClient();
}
// Browser: tái dùng singleton để không mất cache khi Suspense re-render.
browserQueryClient ??= makeQueryClient();
return browserQueryClient;
}
Browser: getQueryClient() ─► singleton (giữ cache qua mọi lần render)
Server: getQueryClient() ─► client MỚI mỗi request (không rò cache giữa user)
Nhớ: server tạo mới, browser singleton — nền cho SSR ở Phần 10.
12. Gotchas thường gặp
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
| Cache “mất” sau mỗi lần state đổi, query chạy lại liên tục | new QueryClient() trong thân component → mỗi render tạo client mới | Tạo ngoài render (module-scope) hoặc useState(() => makeQueryClient()) |
| Query refetch quá nhiều, mạng “cháy” | staleTime mặc định 0 → stale ngay, refetch mỗi mount/focus | Đặt staleTime ≥ vài chục giây ở global/per-key |
| Test chậm/flaky vì retry chờ backoff | retry: 3 mặc định áp cả trong test | Tạo client test riêng với retry: false |
mutation.meta.invalidates báo any / không gợi ý | Chưa augment Register.mutationMeta | Khai báo mutationMeta (mục 7) |
Đặt staleTime: Infinity mà data vẫn biến mất | gcTime 5 phút vẫn dọn entry khi không observer | Nâng cả gcTime (mục 9) |
| Toast lỗi hiện hai lần | Vừa toast ở QueryCache.onError vừa ở hook onError | Chọn một chỗ, hoặc dùng meta.suppressGlobalError |
| Mutation tự chạy hai lần khi mạng chập chờn | Bật retry cho POST không idempotent | Giữ retry: 0; chỉ retry endpoint idempotent |
Recipe client cho test (đặt cạnh test util):
// src/test/query-client.ts
import { QueryClient } from '@tanstack/react-query';
export function makeTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity, staleTime: 0 },
mutations: { retry: false },
},
});
}
retry: falseđể test fail ngay thay vì chờ backoff;gcTime: Infinityđể cache không bị dọn giữa các assertion. Đừng dùngretry: falseở production — bạn cần nó để chịu lỗi mạng tạm thời.
13. Bài tập
1. Một option đặt cả ở defaultOptions.queries lẫn trong lời gọi useQuery. Cái nào thắng, và vì sao thiết kế vậy lại hợp lý?
Lời giải
Hook-level thắng global. Hợp lý vì global là “mặc định an toàn cho số đông”, còn hook-level là ngoại lệ có chủ đích cho một loại data cụ thể (vd cấu hình ít đổi cần staleTime dài). Nếu global mà thắng thì không thể tinh chỉnh từng query.
2. Cùng một key có defaults ở cả ['customers'] và ['customers', 'detail']. Key ['customers', 'detail', 5] nhận bộ defaults nào?
Lời giải
Nhận bộ khớp cụ thể nhất (prefix dài nhất) → ['customers', 'detail']. Hai bộ không được gộp; bộ ít cụ thể hơn (['customers']) bị bỏ qua cho key này.
3. Khi nào nên dùng QueryCache.onError thay vì onError trong từng useQuery?
Lời giải
Dùng QueryCache.onError cho xử lý toàn cục, đồng nhất: đăng xuất khi 401, log về Sentry, toast khi refetch nền thất bại. Dùng onError local khi cần phản ứng đặc thù cho một query (rollback, cập nhật state riêng). Global giảm lặp; local cho kiểm soát chi tiết.
4. Vì sao staleTime: Infinity một mình không đủ để “giữ cache mãi mãi”?
Lời giải
staleTime chỉ điều khiển độ tươi (khi nào refetch), còn gcTime điều khiển khi nào xoá entry khỏi RAM sau khi không còn observer. Với gcTime mặc định 5 phút, entry vẫn bị dọn dù staleTime là vô hạn. Muốn giữ lâu phải nâng cả gcTime (hoặc persist — Phần 13).
5. Phân biệt prefetchQuery, fetchQuery, ensureQueryData. Cái nào hợp nhất cho loader của router?
Lời giải
prefetchQuery nạp để làm ấm cache, nuốt lỗi và không trả data. fetchQuery nạp, trả data và throw khi lỗi. ensureQueryData trả ngay cache nếu có, chỉ fetch khi thiếu (idempotent). Loader thường dùng ensureQueryData để vừa tận dụng cache vừa đảm bảo có data trước khi render.
6. Test bị flaky vì query phải chờ retry backoff. Sửa thế nào, và vì sao không đặt retry: false luôn ở client production?
Lời giải
Tạo một test client riêng (makeTestQueryClient) với retry: false để lỗi nổ ngay, test chạy nhanh và xác định. Production vẫn cần retry để chịu các lỗi mạng/5xx tạm thời — tắt retry ở production làm UX kém bền với chập chờn mạng.
Nâng cao: Thiết lập MutationCache với pattern meta.invalidates (mục 6) cộng augment mutationMeta (mục 7), rồi chuyển 2–3 mutation sang khai báo invalidation qua meta. Xác nhận chỉ còn một chỗ xử lý invalidate cho toàn app, và meta.invalidates được gợi ý kiểu (không any).
Tóm tắt
- Config có ba lớp với độ ưu tiên hook > per-key > global; gộp theo từng option, không lấy nguyên cụm. Đặt mặc định ở global, tinh chỉnh per-key, chỉ chạm hook-level khi là ngoại lệ.
defaultOptions.queriesvàdefaultOptions.mutationscó bảng option đầy đủ; điểm cốt:staleTimemặc định0(dễ over-fetch) và mutation mặc định không retry (tránh ghi trùng).networkMode(online/always/offlineFirst) định đoạt hành vi offline;pausedlà trạng thái “muốn gọi nhưng đang offline”.QueryCache.onError/MutationCache.onErrorlà nơi xử lý lỗi toàn cục (401 → logout, toast khi refetch nền lỗi);MutationCachecòn cho patternmeta.invalidatesđể invalidation tập trung.metaaugment quaRegister→ global handler đọcmetacó kiểu, khôngany.setQueryDefaults/setMutationDefaultscấu hình theo nhóm key (khớp prefix, bộ cụ thể nhất thắng) — phần thưởng của hệ query key phân cấp.staleTime(độ tươi) ≠gcTime(tuổi thọ cache); muốn giữ lâu phải nâng cả hai.- Bảng phương thức
queryClient(invalidate/refetch/cancel/prefetch/fetch/ensure/get/set/getQueryState/remove/reset/clear) là API mệnh lệnh để thao tác cache ngoài hook. - Production: dùng factory
makeQueryClient(); server tạo client mới mỗi request, browser dùng singleton.
Phần tiếp theo
Phần 10 — SSR, Next.js App Router & Hydration: prefetch trên server rồi truyền cache xuống client qua dehydrate + <HydrationBoundary>, dùng getQueryClient() đúng cách trong React Server Components, và streaming data với @tanstack/react-query-next-experimental để vừa giữ SEO vừa có cache tương tác phía client.