jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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. QueryClient vừ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ốn subscribe (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):

APITrả vềDùng khi
getQueryData(key)data hiện tại hoặc undefinedcần snapshot data của đúng một key
getQueriesData(filter)mảng [queryKey, data][] khớp filtercầ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 QueryStateKiểuÝ nghĩa
dataT | undefineddata đã cache
dataUpdatedAtnumberepoch ms lần data cập nhật gần nhất
errorE | nulllỗi gần nhất (nếu có)
errorUpdatedAtnumberepoch 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
isInvalidatedbooleanđã 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 Map trong bộ nhớ. getQueryData chỉ tra Map theo queryHash rồ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 === undefined thì 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á filterKiểuLọc theo
queryKeyQueryKeyprefix khớp (trừ khi có exact)
exactbooleankhớp key chính xác, không theo prefix
type'active' | 'inactive' | 'all'có observer (đang hiển thị) hay không
stalebooleanđang stale hay fresh
predicate(query) => booleanhàm tuỳ ý nhận Query (mục 7)

setQueriesData là vòng lặp setQueryData. 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èm staleTime: 0 để nó refetch nền ngay lần xem đầu.


7. invalidateQueries nâng cao: predicate & refetchType

invalidateQueries đánh dấu query là stale refetch những query đang active. Nhưng bạn kiểm soát được cái nàorefetch 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:

refetchTypeHà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' });

invalidateQueries vs setQueryData: 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ên setQueryData; 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();
APITác động lên cacheRefetch?Dùng khi
cancelQueries(filter)huỷ request đang bay, giữ data hiện tạingay trước optimistic update
invalidateQueries(filter)đánh dấu staleactive (mặc định)data có thể đã cũ, cần hỏi lại
refetchQueries(filter)giữ data, fetch lạiluônép tải lại không quan tâm staleness
resetQueries(filter)về initialData/rỗngactivexoá lịch sử, bắt đầu lại từ đầu
removeQueries(filter)xoá hẳn entrydata chết (đã xoá tài nguyên)
clear()xoá toàn bộ cả hai cachelogout, đổi user
  • cancelQueries cần trước optimistic update: một refetch đang bay có thể về sau và đè lên data optimistic. Huỷ trước để tránh.
  • resetQueries khác removeQueries: reset đưa về initialData rồ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.)

removeQueries không làm component đang xem refetch ngay. Nó xoá entry; observer còn sống sẽ thấy data: undefined rồ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ắc resetQueries.


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.typeKhi 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ả đều subscribe rồi phản ứng theo event.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

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

GotchaHậu quảCách đúng
Sửa data tại chỗ (old.name = x) trong updaterstructural sharing không thấy đổi → không re-render; bẩn snapshot rollbackluôn trả object/array mới ({ ...old }, old.map(...))
subscribekhông unsubscribememory leak; callback chạy chồng sau mỗi lần mountlưu hàm huỷ, gọi trong cleanup (useEffect return)
Mồi detail từ list khi shape khácdata detail thiếu field → UI vỡ ngầmchỉ 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 đọctái dùng key factory, đừng gõ tay queryKey
Bịa entry từ undefined trong updaterstate nửa vời component chưa xử lýif (!old) return old
Broadcast 2 chiều không chặn vòngpost → set → post… lặp vô hạnđánh dấu nguồn (applyingRemote) hoặc dùng broadcastQueryClient
Dùng singleton client trên serverrò data giữa các requestmỗ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

  • QueryClient là 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: setQueryData với updater bất biến (trả undefined khi 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.
  • invalidateQueries mạ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ôn unsubscribe để tránh leak.
  • queryClient là singleton có thể import ngoà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.