jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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ông as (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 ở đâuPhạm viKhi nào dùng
Hook-levelTrong chính useQuery / useMutationMột query/mutationNgoại lệ có chủ đích
Per-keysetQueryDefaults(key, …) / setMutationDefaults(key, …)Mọi key khớp prefixMột nhóm data cùng đặc tính
GlobaldefaultOptions.queries / .mutationsToàn appMặ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 staleTime và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:

OptionKiểuMặc định v5Gợi ý production & vì sao
staleTimenumber | (query) => number060_000. Mặc định 0 khiến data stale ngay, gây refetch thừa mỗi mount/focus.
gcTimenumber300_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).
retryboolean | number | (failureCount, error) => boolean3Hàm bỏ qua 4xx, thử lại tối đa 2 lần cho lỗi mạng/5xx.
retryDelaynumber | (attempt, error) => numberexp backoffBackoff luỹ thừa có trần (30_000).
refetchOnWindowFocusboolean | 'always' | fntruetrue cho data “sống”; tắt cho form đang nhập dở.
refetchOnMountboolean | 'always' | fntruetrue (chỉ refetch khi stale); 'always' ép refetch mọi mount.
refetchOnReconnectboolean | 'always' | fntrueGiữ true để đồng bộ sau khi mạng trở lại.
refetchIntervalnumber | false | fnfalsePolling; nên đặt per-key cho data realtime.
networkMode'online' | 'always' | 'offlineFirst''online'Quyết định hành vi offline (mục 4).
structuralSharingboolean | fntrueGiữ true: nội dung không đổi → tham chiếu không đổi → bớt re-render.
placeholderDataT | (prev) => TkeepPreviousData cho phân trang (Phần 4).
select(data) => UThường đặt hook-level, không global.
enabledbooleantrueĐặt hook-level (dependent query), không global.
throwOnErrorboolean | fnfalsetrue 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:

OptionKiểuMặc địnhGợi ý production & vì sao
retryboolean | number | fn0Giữ 0: thử lại mutation dễ tạo bản ghi trùng (POST hai lần).
retryDelaynumber | fnexp backoffChỉ có ý nghĩa nếu đã bật retry.
networkMode'online' | 'always' | 'offlineFirst''online''offlineFirst' cho hàng đợi offline (Phần 15).
gcTimenumber300_000Bao 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 / onSettledcallbackGlobal ở MutationCache (mục 6) + local ở hook.
metaRecord<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.

HookKhi nào chạyDùng để
onError(error, query)Mỗi khi một query lỗiLog Sentry, toast refetch nền lỗi, logout 401
onSuccess(data, query)Mỗi khi một query thành côngHiế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}`);
    }
  },
});

ApiError là lớp lỗi tự định nghĩa từ Phần 3 (bọc status HTTP). 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.onError chạy trước onError của từng useMutation. 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 metaRecord<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 Register cho cả defaultError và 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àmTham sốKhớp theoBị ghi đè bởi
setQueryDefaults(key, opts)partial query optionsprefix của queryKeyhook-level options
setMutationDefaults(key, opts)partial mutation optionsprefix của mutationKeyhook-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']['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ứcTrả 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 | undefinedGhi 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)voidXoá entry khỏi cache (không refetch)
resetQueries(filters)Promise<void>Đưa query về trạng thái khởi tạo
clear()voidXoá 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ùng ensureQueryData. 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ứngNguyên nhânCách sửa
Cache “mất” sau mỗi lần state đổi, query chạy lại liên tụcnew QueryClient() trong thân component → mỗi render tạo client mớiTạ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ờ backoffretry: 3 mặc định áp cả trong testTạo client test riêng với retry: false
mutation.meta.invalidates báo any / không gợi ýChưa augment Register.mutationMetaKhai báo mutationMeta (mục 7)
Đặt staleTime: Infinity mà data vẫn biến mấtgcTime 5 phút vẫn dọn entry khi không observerNâng cả gcTime (mục 9)
Toast lỗi hiện hai lầnVừa toast ở QueryCache.onError vừa ở hook onErrorChọn một chỗ, hoặc dùng meta.suppressGlobalError
Mutation tự chạy hai lần khi mạng chập chờnBật retry cho POST không idempotentGiữ 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ùng retry: 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']['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ả datathrow 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.queriesdefaultOptions.mutations có bảng option đầy đủ; điểm cốt: staleTime mặc định 0 (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; paused là trạng thái “muốn gọi nhưng đang offline”.
  • QueryCache.onError / MutationCache.onError là nơi xử lý lỗi toàn cục (401 → logout, toast khi refetch nền lỗi); MutationCache còn cho pattern meta.invalidates để invalidation tập trung.
  • meta augment qua Register → global handler đọc meta có kiểu, không any.
  • setQueryDefaults / setMutationDefaults cấ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.