TanStack Query · Phần 12 — QueryClient như một store: thao tác cache chủ động
Đọc-ghi cache trực tiếp với getQueryData/setQueryData và bản số nhiều getQueriesData/setQueriesData, invalidate bằng predicate và refetchType, cancelQueries/removeQueries, rồi subscribe vào cache để đồng bộ chéo.
Hầu hết thời gian bạn để React Query tự quản cache: query đọc, mutation invalidate, phần còn lại Query lo. Nhưng có những lúc bạn cần cầm lái — ghi thẳng vào cache không qua mạng, sửa nhiều query một lúc, đồng bộ hai phần cache liên quan, hay phản ứng với một sự kiện realtime bên ngoài cây React. Lúc đó QueryClient lộ ra mặt thật của nó: một store đọc-ghi được mà bạn thao tác trực tiếp.
Phần này là “API reference có chú giải” cho mặt store của QueryClient, kèm các pattern thực chiến: đọc snapshot, ghi đè bất biến, phát một thay đổi tới nhiều query, subscribe sự kiện cache, và dùng client ngoài React. Đây là nền cho optimistic update đa query (Phần 15) và realtime (Phần 14).
Một singleton, hai mặt.
QueryClientvừa được Provider tiêm vào cây React (để hook đọc qua context), vừa là một object thường mà bạn có thểimportở bất cứ module nào. Mục 11 khai thác chính điều này.
1. Bức tranh lớn: QueryClient chứa gì
QueryClient là lớp vỏ mỏng quanh hai cache: QueryCache (mọi query) và MutationCache (mọi mutation đang/đã chạy). Mỗi entry trong QueryCache là một Query được định danh bằng queryHash (chuỗi JSON ổn định của queryKey).
QueryClient
├── QueryCache
│ ├── Query ['customers','list',{status:'all'}] → state { data, status, dataUpdatedAt }
│ ├── Query ['customers','detail',7] → state { data, status, dataUpdatedAt }
│ └── Query ['cart'] → state { data, status, dataUpdatedAt }
└── MutationCache
├── Mutation updateCustomer → state { status, variables, error }
└── Mutation addToCart → state { status, variables, error }
Mọi API trong phần này thực chất là đường tắt thao tác lên hai cache đó: getQueryData đọc state.data của một Query; setQueryData ghi vào nó; subscribe lắng nghe khi bất kỳ Query nào đổi state.
queryClient.getQueryCache()/getMutationCache()trả về chính hai store bên dưới — bạn ít khi cần, trừ khi muốnsubscribe(mục 9-10) hoặc duyệt toàn bộ entry (.getAll()).
2. Đọc cache: getQueryData, getQueriesData, getQueryState
Ba API đọc, đều đồng bộ (trả ngay, không fetch) và không tạo observer (không làm component re-render):
| API | Trả về | Dùng khi |
|---|---|---|
getQueryData(key) | data hiện tại hoặc undefined | cần snapshot data của đúng một key |
getQueriesData(filter) | mảng [queryKey, data][] khớp filter | cần data của nhiều key qua prefix/predicate |
getQueryState(key) | cả QueryState (hoặc undefined) | cần metadata: status, dataUpdatedAt, error… |
import { queryClient } from '@/lib/query-client';
// Snapshot data (có thể undefined nếu chưa từng cache)
const customer = queryClient.getQueryData<Customer>(customerKeys.detail(id));
// Nhiều entry cùng prefix
const lists = queryClient.getQueriesData<Customer[]>({ queryKey: customerKeys.lists() });
// Toàn bộ state — không chỉ data
const state = queryClient.getQueryState<Customer>(customerKeys.detail(id));
getQueryState cho bạn thứ getQueryData không có: metadata để ra quyết định mà không cần subscribe.
Field của QueryState | Kiểu | Ý nghĩa |
|---|---|---|
data | T | undefined | data đã cache |
dataUpdatedAt | number | epoch ms lần data cập nhật gần nhất |
error | E | null | lỗi gần nhất (nếu có) |
errorUpdatedAt | number | epoch ms lần lỗi gần nhất |
status | 'pending' | 'error' | 'success' | trạng thái data |
fetchStatus | 'fetching' | 'paused' | 'idle' | trạng thái request |
isInvalidated | boolean | đã bị đánh dấu stale thủ công chưa |
// Tự quyết định có cần fetch không, dựa trên tuổi của data
const state = queryClient.getQueryState(customerKeys.detail(id));
const ageMs = state ? Date.now() - state.dataUpdatedAt : Infinity;
if (ageMs > 60_000) {
await queryClient.invalidateQueries({ queryKey: customerKeys.detail(id) });
}
Vì sao đọc đồng bộ lại an toàn? Vì cache là một
Maptrong bộ nhớ.getQueryDatachỉ traMaptheoqueryHashrồi trảstate.data. Không await, không mạng — nên gọi được ở bất kỳ đâu: event handler, callback, module thường.
3. Ghi cache: setQueryData
// Ghi đè trực tiếp bằng giá trị mới
queryClient.setQueryData(customerKeys.detail(id), updatedCustomer);
setQueryData nhận giá trị mới hoặc hàm updater (an toàn hơn vì thấy giá trị hiện tại):
queryClient.setQueryData<Customer>(customerKeys.detail(id), (old) => {
if (!old) return old; // chưa có cache → đừng tạo data từ hư không
return { ...old, name: 'Tên mới' };
});
Vài tính chất quan trọng:
- Cập nhật đồng bộ và làm mọi observer của key đó re-render ngay — không gọi mạng.
- Trả về giá trị vừa set (tiện dùng tiếp).
- Tự cập nhật
dataUpdatedAt = Date.now(). Muốn giữ mốc cũ, truyền option:setQueryData(key, next, { updatedAt: oldUpdatedAt }). - Nếu key chưa tồn tại, một entry mới được tạo (không có observer thì sẽ bị gc sau
gcTime).
Quy tắc vàng: trong updater, nếu
old === undefinedthì trả vềundefined(không đổi). Tự “bịa” một entry từ data một phần dễ tạo state nửa vời mà component không xử lý nổi.
setQueryData là cốt lõi của optimistic update (Phần 6) và cập nhật từ sự kiện realtime (Phần 14): bạn đã biết giá trị mới nên ghi thẳng, khỏi tốn một vòng mạng.
4. Updater bất biến & vai trò của structural sharing
setQueryData không tự clone — bạn phải trả về object mới một cách bất biến, đúng như reducer:
// Cập nhật một phần tử trong list cache
queryClient.setQueryData<Customer[]>(customerKeys.lists(), (old) =>
old?.map((c) => (c.id === id ? { ...c, status: 'active' } : c)),
);
Sau khi bạn trả về data mới, Query chạy structural sharing (Phần 7): những phần tử không đổi giữ nguyên tham chiếu, chỉ phần tử đổi nhận tham chiếu mới. Hệ quả: component render từng row với React.memo chỉ re-render đúng row đã đổi.
old: [A, B, C, D] ← tham chiếu phần tử ban đầu
│ │ ▲ │
│ │ └─ đổi C → C'
▼ ▼ ▼
new: [A, B, C', D]
▲ ▲ ▲
└──┴──────┘ A, B, D giữ NGUYÊN reference → row không re-render
Cấm sửa tại chỗ.
old[0].name = 'x'; return old;không kích hoạt structural sharing đúng cách: tham chiếu mảng không đổi nên Query có thể bỏ qua, và bạn vừa làm bẩn snapshot cũ mà optimistic rollback đang giữ. Luôn{ ...old }/old.map(...).
5. getQueriesData / setQueriesData — nhiều entry qua filter
Khi một thay đổi ảnh hưởng nhiều query (vd đổi tên user xuất hiện ở nhiều list đã lọc/sắp xếp khác nhau), dùng bản số nhiều với query filter:
// Đọc mọi cache khớp prefix ['customers', 'list', ...]
const entries = queryClient.getQueriesData<Customer[]>({
queryKey: customerKeys.lists(),
});
// entries: [[queryKey, data], ...]
// Ghi vào MỌI list cache cùng lúc
queryClient.setQueriesData<Customer[]>(
{ queryKey: customerKeys.lists() },
(old) => old?.map((c) => (c.id === id ? { ...c, name: newName } : c)),
);
Query filter là “ngôn ngữ chọn query” dùng chung cho getQueriesData, setQueriesData, invalidateQueries, removeQueries…:
| Khoá filter | Kiểu | Lọc theo |
|---|---|---|
queryKey | QueryKey | prefix khớp (trừ khi có exact) |
exact | boolean | khớp key chính xác, không theo prefix |
type | 'active' | 'inactive' | 'all' | có observer (đang hiển thị) hay không |
stale | boolean | đang stale hay fresh |
predicate | (query) => boolean | hàm tuỳ ý nhận Query (mục 7) |
setQueriesDatalà vòng lặpsetQueryData. Nó tìm mọi query khớp filter rồi gọi updater cho từng cái. Updater của bạn vẫn phải bất biến và xử lýundefined. Đây là cách “phát sóng” một thay đổi tới mọi biến thể của một query mà không cần biết hết tham số lọc/sắp xếp.
6. Mồi detail từ list & đồng bộ detail ↔ list
Hai cache liên quan — list và detail — nên đi cùng nhau. Khi user sửa chi tiết một item và ta đã có data mới, đồng bộ cả hai mà không refetch:
function syncCustomerEverywhere(updated: Customer) {
// 1) entry chi tiết
queryClient.setQueryData(customerKeys.detail(updated.id), updated);
// 2) mọi list đang cache (mọi filter/sort)
queryClient.setQueriesData<Customer[]>(
{ queryKey: customerKeys.lists() },
(old) => old?.map((c) => (c.id === updated.id ? updated : c)),
);
}
Chiều ngược lại: khi tải một list, “mồi” luôn cache detail cho từng item để mở chi tiết là tức thì (không loading spinner):
function seedDetailsFromList(customers: Customer[]) {
for (const c of customers) {
// Chỉ set nếu CHƯA có, để không đè data detail (có thể đầy đủ hơn).
if (queryClient.getQueryData(customerKeys.detail(c.id)) === undefined) {
queryClient.setQueryData(customerKeys.detail(c.id), c);
}
}
}
Cẩn thận shape: chỉ mồi khi item trong list cùng shape với detail. Nếu detail có thêm field (vd
orders), mồi từ list tạo data thiếu — component detail có thể vỡ ngầm. Khi đó để detail tự fetch; hoặc set kèmstaleTime: 0để nó refetch nền ngay lần xem đầu.
7. invalidateQueries nâng cao: predicate & refetchType
invalidateQueries đánh dấu query là stale và refetch những query đang active. Nhưng bạn kiểm soát được cái nào và refetch ra sao:
// Invalidate có điều kiện tinh vi qua predicate
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'customers' &&
// chỉ invalidate list có filter 'archived'
typeof query.queryKey[2] === 'object' &&
(query.queryKey[2] as { status?: string }).status === 'archived',
});
refetchType quyết định query nào được fetch lại ngay sau khi đánh dấu stale:
refetchType | Hành vi |
|---|---|
'active' (mặc định) | Chỉ refetch query đang có observer (đang hiển thị) |
'all' | Refetch cả query inactive (đang trong cache nhưng không hiển thị) |
'none' | Chỉ đánh dấu stale, không refetch — để lần dùng sau tự fetch |
// Đánh dấu stale nhưng KHÔNG refetch ngay (vd sắp rời trang, refetch là phí)
queryClient.invalidateQueries({ queryKey: customerKeys.all, refetchType: 'none' });
invalidateQueriesvssetQueryData: invalidate = “data này có thể sai, đi hỏi server lại” (tốn request, luôn đúng).setQueryData= “tôi biết giá trị mới, ghi thẳng” (không request, nhưng bạn chịu trách nhiệm về tính đúng). Realtime/optimistic ưu tiênsetQueryData; sau ghi không chắc chắn thìinvalidateQueries.
8. Dọn & huỷ cache: cancelQueries, removeQueries, resetQueries, clear
// Huỷ request đang bay (chuẩn bị optimistic update — Phần 6)
await queryClient.cancelQueries({ queryKey: customerKeys.detail(id) });
// Xoá hẳn entry khỏi cache
queryClient.removeQueries({ queryKey: customerKeys.detail(id) });
// Đưa entry về initialData (hoặc rỗng) rồi refetch nếu đang active
await queryClient.resetQueries({ queryKey: customerKeys.detail(id) });
// Xoá TOÀN BỘ cache (thường khi logout)
queryClient.clear();
| API | Tác động lên cache | Refetch? | Dùng khi |
|---|---|---|---|
cancelQueries(filter) | huỷ request đang bay, giữ data hiện tại | — | ngay trước optimistic update |
invalidateQueries(filter) | đánh dấu stale | active (mặc định) | data có thể đã cũ, cần hỏi lại |
refetchQueries(filter) | giữ data, fetch lại | luôn | ép tải lại không quan tâm staleness |
resetQueries(filter) | về initialData/rỗng | active | xoá lịch sử, bắt đầu lại từ đầu |
removeQueries(filter) | xoá hẳn entry | — | data chết (đã xoá tài nguyên) |
clear() | xoá toàn bộ cả hai cache | — | logout, đổi user |
cancelQueriescần trước optimistic update: một refetch đang bay có thể về sau và đè lên data optimistic. Huỷ trước để tránh.resetQuerieskhácremoveQueries: reset đưa vềinitialDatarồi (nếu active) fetch lại — entry vẫn tồn tại; remove xoá sạch, lần sau dùng mới tạo lại từpending.- Khi logout,
queryClient.clear()xoá tất cả, tránh user mới thấy data user cũ. (Sau đó nhớ reset cả các mutation đang treo.)
removeQuerieskhông làm component đang xem refetch ngay. Nó xoá entry; observer còn sống sẽ thấydata: undefinedrồi tự khởi động lại một fetch. Nếu muốn “xoá rồi tải lại” mượt hơn, cân nhắcresetQueries.
9. Subscribe vào QueryCache
QueryCache phát sự kiện mỗi khi có thay đổi. subscribe để chạy side-effect ngoài vòng render — ghi log, đồng bộ hai cache, bắc cầu sang store khác, hay broadcast đa tab:
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.type === 'updated' && event.query.queryKey[0] === 'cart') {
const cart = event.query.state.data as CartItem[] | undefined;
updateCartBadge(cart?.length ?? 0); // cập nhật badge NGOÀI React tree
}
});
// Nhớ huỷ khi không cần nữa.
unsubscribe();
event là một object { type, query, action? }. event.type cho biết chuyện gì xảy ra:
event.type | Khi nào phát |
|---|---|
'added' | một query mới được tạo trong cache |
'removed' | query bị xoá (gc hoặc removeQueries) |
'updated' | state đổi (fetch, setQueryData, invalidate…) |
'observerAdded' | một component bắt đầu observe query |
'observerRemoved' | observer cuối rời đi (bắt đầu đếm gcTime) |
'observerResultsUpdated' | kết quả nhìn thấy của observer đổi |
Với 'updated', event.action.type cho biết nguyên nhân: 'success', 'error', 'fetch', 'setState', 'invalidate'… — hữu ích để lọc đúng loại thay đổi.
Đây là cơ chế nền của Devtools và các tiện ích như
broadcastQueryClient/persistQueryClient(Phần 13): tất cả đềusubscriberồi phản ứng theoevent.type.
10. Subscribe vào MutationCache
Tương tự, getMutationCache().subscribe(...) cho sự kiện mutation — tiện cho logging/telemetry tập trung:
queryClient.getMutationCache().subscribe((event) => {
const m = event.mutation;
if (event.type === 'updated' && m?.state.status === 'error') {
reportError(m.state.error, { variables: m.state.variables });
}
});
Một chỗ duy nhất bắt mọi lỗi mutation toàn app — không phải rải onError khắp nơi. (Có thể thay bằng new MutationCache({ onError }) khi tạo client; subscribe linh hoạt hơn khi cần bật/tắt động.)
11. Dùng QueryClient ngoài React
Vì queryClient chỉ là một object, bạn import nó ở bất cứ đâu — không cần hook, không cần component. Điều kiện: tạo một singleton và export dùng chung.
// src/lib/query-client.ts — tạo MỘT lần, export dùng chung
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } },
});
Trong React, truyền vào Provider; ngoài React, import thẳng:
// src/app/providers.tsx
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/lib/query-client';
export function Providers({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
// src/lib/realtime.ts — KHÔNG phải component, vẫn ghi được cache
import { queryClient } from '@/lib/query-client';
import { customerKeys } from '@/lib/customer-keys';
export function connectRealtime(socket: WebSocket): void {
socket.addEventListener('message', (e) => {
const msg = JSON.parse(e.data) as { type: 'customer.updated'; data: Customer };
if (msg.type === 'customer.updated') {
// Sự kiện server → ghi thẳng cache, mọi component đang xem cập nhật ngay.
queryClient.setQueryData(customerKeys.detail(msg.data.id), msg.data);
}
});
}
Cảnh báo SSR: một singleton module-level chỉ an toàn ở trình duyệt. Trên server (Next.js App Router), mỗi request phải có client riêng để không rò data giữa người dùng (Phần 10). Mẫu “singleton import” trong mục này dành cho code chạy ở client (websocket, event handler trình duyệt).
12. Recipes thực chiến
Cache event logger (chỉ ở dev):
import type { QueryClient } from '@tanstack/react-query';
export function installCacheLogger(client: QueryClient): () => void {
return client.getQueryCache().subscribe((event) => {
if (import.meta.env.PROD) return;
console.debug('[qc]', event.type, event.query.queryHash, {
status: event.query.state.status,
updatedAt: event.query.state.dataUpdatedAt,
});
});
}
Đồng bộ cache thủ công từ websocket (gói gọn pattern mục 6 + 11):
function applyServerPatch(patch: { id: string; changes: Partial<Customer> }): void {
queryClient.setQueryData<Customer>(customerKeys.detail(patch.id), (old) =>
old ? { ...old, ...patch.changes } : old,
);
queryClient.setQueriesData<Customer[]>(
{ queryKey: customerKeys.lists() },
(old) => old?.map((c) => (c.id === patch.id ? { ...c, ...patch.changes } : c)),
);
}
Broadcast đa tab (phiên bản thủ công, minh hoạ):
const channel = new BroadcastChannel('qc-sync');
let applyingRemote = false; // chặn vòng lặp post → set → post
const unsub = queryClient.getQueryCache().subscribe((event) => {
if (applyingRemote) return;
if (event.type === 'updated' && event.query.queryKey[0] === 'cart') {
channel.postMessage({ key: event.query.queryKey, data: event.query.state.data });
}
});
channel.onmessage = (e) => {
applyingRemote = true;
queryClient.setQueryData(e.data.key, e.data.data);
applyingRemote = false;
};
Đừng tự viết broadcast cho production. Bản trên thiếu xử lý version/echo/đồng bộ ban đầu. Dùng
broadcastQueryClient(Phần 13) — nó lo hết. Recipe này chỉ để hiểu cơ chế.
13. Gotchas thường gặp
| Gotcha | Hậu quả | Cách đúng |
|---|---|---|
Sửa data tại chỗ (old.name = x) trong updater | structural sharing không thấy đổi → không re-render; bẩn snapshot rollback | luôn trả object/array mới ({ ...old }, old.map(...)) |
subscribe mà không unsubscribe | memory leak; callback chạy chồng sau mỗi lần mount | lưu hàm huỷ, gọi trong cleanup (useEffect return) |
| Mồi detail từ list khi shape khác | data detail thiếu field → UI vỡ ngầm | chỉ seed khi cùng shape; nếu thiếu, để detail tự fetch |
setQueryData với key sai (thiếu phần tử) | tạo entry “ma” không observer nào đọc | tái dùng key factory, đừng gõ tay queryKey |
Bịa entry từ undefined trong updater | state nửa vời component chưa xử lý | if (!old) return old |
| Broadcast 2 chiều không chặn vòng | post → set → post… lặp vô hạn | đánh dấu nguồn (applyingRemote) hoặc dùng broadcastQueryClient |
| Dùng singleton client trên server | rò data giữa các request | mỗi request một client (Phần 10) |
14. Bài tập
1. Khi nào nên dùng setQueryData thay vì invalidateQueries, và đánh đổi là gì?
Lời giải
Dùng setQueryData khi bạn đã biết chắc giá trị mới (kết quả mutation, sự kiện realtime) — cập nhật tức thì, không tốn request. Đánh đổi: bạn chịu trách nhiệm về tính đúng; nếu shape lệch hoặc server còn biến đổi data, cache sẽ sai. invalidateQueries luôn đúng (hỏi lại server) nhưng tốn request và có độ trễ. Thực tế hay kết hợp: setQueryData cho tức thì, rồi invalidateQueries để chốt sự thật.
2. refetchType: 'none' dùng để làm gì?
Lời giải
Đánh dấu query là stale nhưng không refetch ngay. Hợp khi bạn biết data đã cũ nhưng chưa cần fetch lại lúc này (vd sắp điều hướng đi, hoặc query inactive) — lần sau query được dùng/được focus nó sẽ tự refetch. Tránh tạo request thừa ngay tại thời điểm invalidate.
3. Vì sao phải cancelQueries trước khi optimistic update?
Lời giải
Một refetch đang bay có thể resolve sau khi bạn ghi data optimistic và đè lên nó bằng data cũ từ server, làm UI nhảy ngược. cancelQueries huỷ các request đang bay cho key đó trước, đảm bảo data optimistic không bị ghi đè cho tới khi mutation hoàn tất.
4. Cùng cần đọc cache đồng bộ, khi nào dùng getQueryState thay cho getQueryData?
Lời giải
getQueryData chỉ trả data; getQueryState trả cả status, fetchStatus, error, dataUpdatedAt, isInvalidated. Dùng getQueryState khi quyết định phụ thuộc vào metadata chứ không chỉ giá trị — vd “data cũ hơn 60s thì mới invalidate” (đọc dataUpdatedAt), hay “chỉ ghi đè nếu query không đang fetch” (đọc fetchStatus). Cả hai đều đồng bộ và không tạo observer.
5. Viết một hook useCartBadgeSync() subscribe vào QueryCache để cập nhật badge, đảm bảo không leak.
Lời giải
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
export function useCartBadgeSync(onChange: (count: number) => void): void {
const queryClient = useQueryClient();
useEffect(() => {
// subscribe() TRẢ VỀ hàm huỷ → trả thẳng nó trong cleanup.
return queryClient.getQueryCache().subscribe((event) => {
if (event.type === 'updated' && event.query.queryKey[0] === 'cart') {
const cart = event.query.state.data as CartItem[] | undefined;
onChange(cart?.length ?? 0);
}
});
}, [queryClient, onChange]);
}Mấu chốt: subscribe() trả về hàm unsubscribe; trả nó trực tiếp trong cleanup của useEffect để mỗi lần effect chạy lại (hoặc unmount) đều huỷ đăng ký cũ — không chồng listener.
Nâng cao: Viết syncCustomerEverywhere ở mục 6 và gọi nó trong onSuccess của mutation sửa. Mở Devtools, sửa một customer, xác nhận cả entry detail lẫn các list đều cập nhật ngay mà không có request refetch nào. Sau đó thêm seedDetailsFromList vào onSuccess của query list và kiểm chứng mở chi tiết không còn loading spinner.
Tóm tắt
QueryClientlà lớp vỏ quanh hai cache (QueryCache+MutationCache); mọi API store ở đây chỉ là đường tắt thao tác lên chúng.- Đọc đồng bộ:
getQueryData(data),getQueriesData(nhiều entry qua filter),getQueryState(cả metadata:status,dataUpdatedAt,fetchStatus…). Không tạo observer. - Ghi:
setQueryDatavới updater bất biến (trảundefinedkhi chưa có cache); structural sharing giữ tham chiếu phần tử không đổi → re-render tối thiểu. getQueriesData/setQueriesData+ query filter (queryKey/exact/type/stale/predicate) cập nhật nhiều entry cùng lúc.- Đồng bộ detail ↔ list bằng
setQueryData/setQueriesData; mồi detail từ list — chỉ khi shape khớp. invalidateQueriesmạnh nhờpredicate+refetchType(active/all/none).- Dọn cache:
cancelQueries(trước optimistic),resetQueries(về đầu),removeQueries(xoá hẳn),clear()(logout). getQueryCache().subscribe(...)/getMutationCache().subscribe(...)chạy side-effect ngoài React — nền của Devtools, logger, broadcast. Luônunsubscribeđể tránh leak.queryClientlà singleton có thểimportngoài React (websocket, event handler) — nhưng trên server phải mỗi request một client.
Phần tiếp theo
Phần 13 — Offline-first & Persistence sâu: giữ cache qua reload bằng persistQueryClient + persister (sync/async), kiểm soát buster/maxAge, lọc dữ liệu nhạy cảm khi dehydrate, hàng đợi paused mutations chạy lại khi có mạng, và đồng bộ cache đa tab bằng broadcastQueryClient.