jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TanStack Query · Phần 7 — Errors, Retry, Suspense & Performance

Cấu hình retry thông minh, ném lỗi lên Error Boundary với throwOnError, dùng useSuspenseQuery cho loading khai báo, và tối ưu hiệu năng với select, structural sharing và prefetching.

Phần 6 làm UI cảm giác tức thì. Phần này làm UI bềnmượt: xử lý lỗi tử tế khi mạng chập chờn, retry đúng loại lỗi, dùng Suspense cho loading khai báo, và cắt re-render thừa bằng select + structural sharing. Đây là tầng biến một app “chạy được trên máy mình” thành một app “chạy mượt cho người dùng thật”.

Ta đi theo hai trục: độ bền (errors → retry → Suspense) và hiệu năng (select → structural sharing → tracked properties → prefetch). Mỗi mục đào tới cơ chế bên dưới, kèm bảng tra cứu để bạn quay lại tra nhanh.


1. Bản đồ hai trục

                ┌──────────────── ĐỘ BỀN ────────────────┐
  query lỗi  →  error/isError  →  retry  →  throwOnError  →  Suspense
                (đọc trạng thái)  (thử lại)  (Error Boundary)  (khai báo)

                ┌──────────────── HIỆU NĂNG ──────────────┐
  data đổi   →  structural    →  select   →  notifyOn   →  prefetch
                sharing           (narrow)    ChangeProps    (mồi cache)
                (giữ ref)         (subscribe ít)  (re-render ít)

Hai trục này độc lập nhưng bổ trợ: một query có thể đồng thời retry thông minh, ném lỗi lên boundary, chỉ re-render khi đúng field đổi.


2. error vs isError — đọc trạng thái lỗi đúng cách

useQuery phơi lỗi qua nhiều field. Hiểu rõ từng field tránh được kiểu code “lúc có lúc không”:

FieldKiểuÝ nghĩa
isErrorbooleantrue khi status === 'error' (lần fetch gần nhất thất bại)
errorTError | nullĐối tượng lỗi queryFn ném ra; null khi không lỗi
isLoadingErrorbooleanLỗi ở lần fetch đầu (chưa từng có data)
isRefetchErrorbooleanLỗi khi refetch đã có data cũ trong cache
failureCountnumberSố lần thất bại liên tiếp (reset về 0 khi thành công)
failureReasonTError | nullLỗi của lần thử gần nhất — có giá trị ngay trong lúc đang retry
status'pending' | 'error' | 'success'Trạng thái của data
fetchStatus'fetching' | 'paused' | 'idle'Trạng thái của đường mạng

Cơ chế cần nắm: error chỉ được set khi query cạn số lần retry. Trong lúc còn đang retry, status vẫn là 'pending', nhưng failureReason đã có lỗi của lần thử vừa rồi — dùng nó để hiện “Đang thử lại… (lỗi mạng)” mà chưa coi là thất bại hẳn.

function Report() {
  const { data, isPending, isError, error, failureReason, failureCount } = useQuery({
    queryKey: ['report'],
    queryFn: fetchReport,
  });

  // Đang retry: chưa lỗi hẳn, nhưng cho user biết đang trục trặc.
  if (isPending) {
    return failureCount > 0
      ? <Spinner note={`Đang thử lại… (${failureReason?.message})`} />
      : <Spinner />;
  }
  if (isError) return <ErrorState message={error.message} />; // error: TError, không null
  return <ReportView data={data} />;
}

Phân biệt isLoadingError vs isRefetchError: lỗi lần đầu (chưa có data) thường cần một màn hình lỗi toàn phần; lỗi khi refetch (đã có data cũ) chỉ nên hiện một toast nhẹ và giữ nguyên data cũ — đừng xoá màn hình đang dùng được.


3. Retry — thử lại thông minh

3.1. retryretryDelay

Mặc định, query lỗi được thử lại 3 lần với khoảng chờ tăng dần (exponential backoff). Mutation mặc định không retry. Bảng giá trị retry:

Giá trị retryHành vi
3 (mặc định query)Thử lại tối đa 3 lần rồi mới set isError
0 (mặc định mutation)Không thử lại; lỗi là lỗi luôn
falseTương đương 0
trueThử lại vô hạn (cẩn thận — chỉ hợp lý khi kèm điều kiện ngoài)
(failureCount, error) => booleanQuyết định theo từng lỗi — mạnh nhất
useQuery({
  queryKey: ['report'],
  queryFn: fetchReport,
  retry: 2,                 // số lần thử lại
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), // backoff, trần 30s
});

3.2. retryDelay — công thức exponential backoff

Mặc định retryDelayMath.min(1000 * 2 ** attempt, 30_000) — chờ gấp đôi sau mỗi lần, trần 30s. attempt đếm từ 0:

attempt:   0      1      2      3       4        …
delay:   1000ms  2000ms 4000ms 8000ms  16000ms  (… cap 30000ms)

  fetch ✗ ──1s──▶ retry ✗ ──2s──▶ retry ✗ ──4s──▶ retry ✗ ──▶ isError

Tăng theo cấp số nhân giúp không dồn request vào lúc server đang ngộp (nếu mọi client retry cùng lúc đều đặn, server bị “thundering herd”). Muốn thêm jitter (lệch ngẫu nhiên để tránh đồng pha), cộng một lượng nhỏ ngẫu nhiên:

retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000) + Math.random() * 1000,

3.3. Không phải lỗi nào cũng nên retry

Lỗi 404 (không tồn tại) hay 401/403 (không có quyền) thử lại vô ích — chỉ tổ làm chậm và spam request. Dùng retry dạng hàm để chỉ thử lại lỗi mạng/5xx:

import { ApiError } from '@/lib/api-client'; // từ Phần 3

useQuery({
  queryKey: ['report'],
  queryFn: fetchReport,
  retry: (failureCount, error) => {
    // Lỗi client (4xx) → không thử lại, vô nghĩa.
    if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
      return false;
    }
    // Còn lại (mạng, 5xx) → thử tối đa 3 lần.
    return failureCount < 3;
  },
});

3.4. networkMode — hành vi khi offline

networkMode quyết định query/mutation xử sự thế nào khi không có mạng:

networkModeKhi offlineDùng cho
'online' (mặc định)Không chạy queryFn; chuyển fetchStatus: 'paused', tự chạy lại khi có mạngApp phụ thuộc server hoàn toàn
'always'Luôn chạy queryFn bất kể mạngData từ cache/AsyncStorage/local, không cần mạng
'offlineFirst'Chạy queryFn một lần; nếu lỗi thì pause + retry khi có mạngCó Service Worker / HTTP cache đáp ứng được lần đầu

fetchStatus: 'paused' không phải lỗi. Khi offline với mode 'online', query “đứng chờ” chứ không thất bại — UI nên hiện trạng thái “ngoại tuyến, sẽ tải lại khi có mạng” thay vì màn hình lỗi. Đây chính là lý do fetchStatus tách khỏi status (xem Phần 1).

3.5. Retry trên mutation

Mutation mặc định retry: 0 vì rủi ro: thử lại một POST có thể tạo bản ghi trùng. Chỉ bật retry khi endpoint idempotent (PUT/DELETE theo id):

useMutation({
  mutationFn: (c: Customer) => api.put(`/customers/${c.id}`, c), // idempotent
  retry: 2,
  retryDelay: (attempt) => 1000 * 2 ** attempt,
});

4. throwOnError & Error Boundaries

4.1. throwOnError — boolean và function

Kiểm tra isError ở từng component lặp đi lặp lại rất mệt. Với những lỗi “nghiêm trọng” (không có data thì không render được), tiện hơn là ném lỗi lên Error Boundary và xử lý tập trung.

throwOnError nhận hai dạng:

DạngÝ nghĩa
trueMọi lỗi đều ném lên Error Boundary gần nhất
false (mặc định useQuery)Không ném; tự xử lý qua isError
(error, query) => booleanChọn lọc: ném lỗi nào, giữ lỗi nào
useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  // Chỉ ném 5xx lên boundary; 4xx tự xử lý inline (vd form validation).
  throwOnError: (error) =>
    error instanceof ApiError && error.status >= 500,
});

useSuspenseQuery mặc định throwOnError: true — đó là lý do nó luôn cần một Error Boundary bao quanh.

4.2. Error Boundary + useQueryErrorResetBoundary

React không có sẵn Error Boundary — dùng react-error-boundary. Vấn đề: sau khi boundary bắt lỗi, query vẫn ở trạng thái error trong cache; bấm “Thử lại” mà không reset thì boundary lại bắt đúng lỗi cũ. QueryErrorResetBoundary (component) hoặc useQueryErrorResetBoundary (hook) giải quyết:

import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';

function ProfileSection() {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset} // reset trạng thái lỗi của query khi boundary reset
          fallbackRender={({ resetErrorBoundary }) => (
            <div role="alert">
              <p>Không tải được hồ sơ.</p>
              <button onClick={resetErrorBoundary}>Thử lại</button>
            </div>
          )}
        >
          <Profile />
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

Dùng hook khi cần reset từ chỗ khác (vd một nút “Tải lại tất cả” ở header):

import { useQueryErrorResetBoundary } from '@tanstack/react-query';

function Header() {
  const { reset } = useQueryErrorResetBoundary();
  return <button onClick={reset}>Tải lại tất cả</button>;
}

4.3. Lỗi toàn cục: QueryCache.onError

Lỗi cần xử lý một chỗ (401 → đăng xuất, log về Sentry, toast chung) nên đặt ở QueryCache/MutationCache khi tạo QueryClient, thay vì lặp ở mỗi query:

import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      if (error instanceof ApiError && error.status === 401) {
        logout(); // hết phiên → đăng xuất tập trung
        return;
      }
      // Chỉ toast nếu query không tự xử lý lỗi (tránh toast trùng).
      if (query.meta?.silent !== true) {
        toast.error(error.message);
      }
    },
  }),
  mutationCache: new MutationCache({
    onError: (error) => Sentry.captureException(error),
  }),
});

query.meta là túi metadata tuỳ ý gắn vào từng query — ở đây dùng meta.silent để query nào đó chọn không hiện toast chung. Đây là mẫu rất gọn để tách “lỗi nền” khỏi “lỗi cần báo”.


5. useSuspenseQuery — loading khai báo

5.1. data không bao giờ undefined

React Query v5 có useSuspenseQuery: thay vì trả isPending, nó suspend component cho tới khi có data, để <Suspense> lo loading và Error Boundary lo lỗi. Code component sạch hơn vì data không bao giờ undefined — TypeScript thu hẹp type giúp bạn:

import { useSuspenseQuery } from '@tanstack/react-query';

function Profile() {
  // Không có isPending/isError ở đây — Suspense & ErrorBoundary lo.
  const { data } = useSuspenseQuery(profileQuery());
  return <h1>{data.name}</h1>; // data: Profile (KHÔNG phải Profile | undefined)
}

function ProfilePage() {
  return (
    <ErrorBoundary fallbackRender={() => <ErrorState />}>
      <Suspense fallback={<ProfileSkeleton />}>
        <Profile />
      </Suspense>
    </ErrorBoundary>
  );
}

5.2. useSuspenseQuery vs useQuery

Khía cạnhuseQueryuseSuspenseQuery
data ban đầuTData | undefinedTData (luôn có)
LoadingisPending trong component<Suspense fallback> ở biên
LỗiisError trong componentError Boundary ở biên (throwOnError: true)
enabledKhông hỗ trợ (luôn chạy)
placeholderDataKhông
Rủi ro chínhBoilerplate loading/errorWaterfall khi lồng nhiều suspense
Hợp khiCần kiểm soát loading inlineMuốn loading khai báo, data chắc chắn

5.3. Coi chừng waterfall + ghi chú streaming

Component bị suspend sẽ chặn render con — nếu hai useSuspenseQuery lồng nhau (cha suspend xong mới tới lượt con fetch), bạn vô tình tạo waterfall tuần tự. Để chạy song song, gọi các query cạnh nhau trong cùng một component, hoặc dùng useSuspenseQueries:

import { useSuspenseQueries } from '@tanstack/react-query';

function Dashboard() {
  // Hai query fetch SONG SONG, cùng suspend một lần.
  const [{ data: user }, { data: stats }] = useSuspenseQueries({
    queries: [userQuery(), statsQuery()],
  });
  return <DashboardView user={user} stats={stats} />;
}

Streaming SSR (Next.js App Router): với <Suspense>, server có thể stream HTML phần đã sẵn sàng và “lấp” phần suspend sau, kèm hydrate data từ server (prefetch + dehydrate). Chi tiết để dành cho phần SSR/Hydration nâng cao trong series — ở đây chỉ cần biết useSuspenseQuery là viên gạch nền cho streaming.


6. select — biến đổi & thu hẹp re-render

select cho phép biến đổi data trước khi component nhận, và quan trọng hơn: component chỉ re-render khi phần được select đổi, không phải khi toàn bộ data đổi.

// Chỉ lấy số lượng — re-render khi COUNT đổi, không phải khi từng customer đổi.
function CustomerCount() {
  const { data: count } = useQuery({
    ...customersQuery(),
    select: (data) => data.length,
  });
  return <span>{count} khách hàng</span>;
}

// Hoặc biến đổi shape
function CustomerNames() {
  const { data: names } = useQuery({
    ...customersQuery(),
    select: (data) => data.map((c) => c.name), // chỉ tên
  });
  return <ul>{names?.map((n) => <li key={n}>{n}</li>)}</ul>;
}

Cơ chế: sau mỗi lần data nguồn đổi, React Query chạy select, rồi so sánh cấu trúc kết quả với kết quả lần trước. Nếu giống hệt → trả về tham chiếu cũ → observer không re-render. Nghĩa là dù customersQuery refetch liên tục, CustomerCount chỉ re-render khi con số thực sự đổi.

Mẹo hiệu năng: select chạy mỗi lần render khi data có. Nếu phép biến đổi nặng, dùng một hàm select ổn định tham chiếu (định nghĩa ngoài component hoặc bọc useCallback) để React Query bỏ qua tính lại khi data không đổi. Với data lớn, select + structural sharing (mục 7) cắt re-render rất hiệu quả.


7. Structural sharing — vì sao tham chiếu ổn định

React Query mặc định bật structural sharing: sau mỗi lần refetch, nó so sánh data mới với data cũ và giữ nguyên tham chiếu của những phần không đổi.

data cũ:  { items: [A, B, C], page: 1 }
data mới: { items: [A, B, C'], page: 1 }   ← chỉ C đổi thành C'
                     │  │  │
kết quả:  items[0]=A (ref cũ), items[1]=B (ref cũ), items[2]=C' (ref mới)
          → page giữ ref cũ, A/B giữ ref cũ; chỉ C' và mảng items là mới

Hệ quả: nếu refetch trả về data y hệt, data === data cũ (cùng tham chiếu) → component không re-render thừa, và các useMemo/React.memo/select phía dưới vẫn hiệu lực vì tham chiếu phần không đổi được bảo toàn.

Tình huốngCó structural sharing
Refetch trả data y hệtdata giữ nguyên ref → 0 re-render
Refetch đổi 1 phần tử trong listPhần tử khác giữ ref, React.memo con không đổi vẫn skip
Tắt structuralSharing: falseMọi refetch tạo object mới → re-render toàn bộ
Data không JSON-serializable (Map, Class)So sánh cấu trúc không áp dụng được → cân nhắc custom

Bạn gần như không phải làm gì — chỉ cần biết: đừng tự ý map/clone data ngoài select, và queryFn nên trả về JSON-serializable data để so sánh cấu trúc hoạt động tốt.


8. notifyOnChangeProps & tracked properties — cắt re-render

useQuery trả về cả chục field (data, isPending, isFetching, dataUpdatedAt…). Mặc định v5 dùng tracked properties: nó theo dõi bạn thực sự đọc field nào (qua getter) và chỉ re-render khi field đó đổi.

function CustomerName() {
  // Chỉ đọc `data` → component KHÔNG re-render khi `isFetching` bật/tắt
  // (vd lúc refetch nền), chỉ re-render khi `data` đổi.
  const { data } = useQuery(customerQuery(id));
  return <span>{data?.name}</span>;
}

Muốn ghim cứng danh sách field cần nghe, dùng notifyOnChangeProps:

notifyOnChangePropsHành vi
'all'Re-render khi bất kỳ field nào đổi (tắt tracked properties)
['data', 'error']Chỉ re-render khi data hoặc error đổi
Bỏ trống (mặc định)Tracked properties — theo field bạn destructure
useQuery({
  ...customerQuery(id),
  // Bỏ qua isFetching/isStale… chỉ quan tâm data & error.
  notifyOnChangeProps: ['data', 'error'],
});

Bẫy phổ biến: destructure cả isFetching “cho chắc” rồi không dùng vẫn khiến component nghe field đó → re-render mỗi lần refetch nền. Chỉ destructure field bạn thật sự render.


9. Prefetching — tải trước cho cảm giác tức thì

Tài liệu: Prefetching.

Prefetch là nạp data vào cache trước khi component cần, để khi user tới nơi thì data đã sẵn. Hai chỗ dùng phổ biến:

Prefetch khi hover (link/row)

function CustomerRow({ customer }: { customer: Customer }) {
  const qc = useQueryClient();

  return (
    <Link
      to={`/customers/${customer.id}`}
      // Hover là tín hiệu "sắp bấm" → nạp trước data chi tiết.
      onMouseEnter={() => qc.prefetchQuery(customerQuery(customer.id))}
    >
      {customer.name}
    </Link>
  );
}

prefetchQuery tôn trọng staleTime: nếu data còn fresh, nó không fetch lại — nên gọi hover liên tục cũng không spam request.

Prefetch trong route loader (React Router)

Nạp data ngay khi điều hướng bắt đầu (song song với tải code component), tránh waterfall “render rồi mới fetch”. Dùng ensureQueryData (trả data đã cache nếu có, fetch nếu chưa):

// loader của route /customers/:id
export async function customerLoader({ params }: { params: { id: string } }) {
  // Không trả về data; chỉ "mồi" cache. Component vẫn dùng useQuery như thường.
  await queryClient.ensureQueryData(customerQuery(params.id));
  return null;
}

Khi component mount và gọi useQuery(customerQuery(id)), data đã nằm sẵn trong cache → hiển thị tức thì, không spinner.

Thứ tự tối ưu hiệu năng (nhớ làm theo): bundle (code splitting + lazy route) → data (prefetch / tránh waterfall) → re-render (select / structural sharing / tracked props). Đo bằng React Profiler và bundle visualizer trước khi tối ưu — đừng tối ưu mò.


10. Persistence (ghi nhớ cache qua refresh) — xem nhanh

Mặc định cache nằm trong RAM, mất khi reload trang. Nếu muốn giữ cache qua các lần load (offline-first, mở app thấy data cũ ngay), dùng persister chính thức:

pnpm add @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

const persister = createSyncStoragePersister({ storage: window.localStorage });

<PersistQueryClientProvider client={queryClient} persistOptions={{ persister }}>
  <App />
</PersistQueryClientProvider>;

Cẩn thận: đừng persist data nhạy cảm (token, thông tin cá nhân) vào localStorage. Lọc bằng dehydrateOptions nếu cần. (Phần Offline-first & Persistence sâu trong series sẽ đào kỹ chủ đề này.)


11. Gotchas thường gặp

Triệu chứngNguyên nhânCách xử lý
Lỗi 404 vẫn quay vòng 3 lần rồi mới hiệnretry mặc định 3, không phân biệt loại lỗiretry dạng hàm: false cho 4xx
App “treo” khi offline, không báo gìfetchStatus: 'paused' bị tưởng là lỗiHiện trạng thái ngoại tuyến; tách statusfetchStatus
Bấm “Thử lại” trên boundary nhưng lại lỗi ngayKhông reset trạng thái lỗi của queryBọc QueryErrorResetBoundary + onReset={reset}
useSuspenseQuery báo “ném ra ngoài Suspense”Thiếu <Suspense> hoặc Error Boundary bao quanhLuôn đặt cả hai ở biên
Hai suspense query chạy tuần tự (chậm)Lồng nhau gây waterfallĐặt cạnh nhau / useSuspenseQueries
Component re-render mỗi lần refetch nềnDestructure isFetching nhưng không cầnChỉ destructure field render; hoặc notifyOnChangeProps
select chạy lại liên tục, tốn CPUHàm select tạo mới mỗi render + data lớnĐịnh nghĩa select ổn định ref / useCallback
Mutation tạo bản ghi trùng khi mạng chậpBật retry cho POST không idempotentChỉ retry endpoint idempotent (PUT/DELETE)
Toast lỗi hiện hai lầnVừa onError cục bộ vừa QueryCache.onErrorDùng query.meta.silent để tắt toast chung

12. Recipes thực chiến

12.1. Error UI dựa trên boundary (tái dùng)

import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import type { ReactNode } from 'react';

// Bọc bất kỳ vùng nào cần "ném lỗi → boundary" tập trung.
export function QueryBoundary({ children }: { children: ReactNode }) {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={({ error, resetErrorBoundary }) => (
            <div role="alert" className="error-card">
              <p>Đã có lỗi: {error.message}</p>
              <button onClick={resetErrorBoundary}>Thử lại</button>
            </div>
          )}
        >
          {children}
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

12.2. Backoff theo loại lỗi (defaults toàn cục)

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
          return false; // 4xx: bỏ
        }
        return failureCount < 3; // mạng/5xx: tối đa 3
      },
      retryDelay: (attempt) =>
        Math.min(1000 * 2 ** attempt, 30_000) + Math.random() * 1000, // + jitter
    },
  },
});

12.3. Danh sách dùng Suspense, fetch song song

function CustomerListPage() {
  return (
    <QueryBoundary>
      <Suspense fallback={<ListSkeleton />}>
        <CustomerList />
      </Suspense>
    </QueryBoundary>
  );
}

function CustomerList() {
  const { data } = useSuspenseQuery(customersQuery()); // data: Customer[]
  return (
    <ul>
      {data.map((c) => (
        <li key={c.id}>{c.name}</li>
      ))}
    </ul>
  );
}

13. Bài tập

1. Vì sao không nên retry lỗi 404 hay 401, và cấu hình retry thế nào để chỉ thử lại lỗi mạng/5xx?

Lời giải

404/401/403 là lỗi định mệnh — thử lại vẫn lỗi y vậy, chỉ làm chậm và spam request. Dùng retry dạng hàm: trả false khi error.status trong khoảng 4xx, còn lại trả failureCount < n để thử lại lỗi mạng/5xx.

2. useSuspenseQuery khác useQuery ở điểm nào, và rủi ro chính khi lạm dụng là gì?

Lời giải

useSuspenseQuery suspend component cho tới khi có data (loading/error đẩy lên Suspense + Error Boundary), nên data không bao giờ undefined và mặc định throwOnError: true. Nó không hỗ trợ enabled/placeholderData. Rủi ro: lồng nhiều suspense query gây waterfall vì component bị suspend chặn render con. Khắc phục bằng cách đặt query cạnh nhau hoặc dùng useSuspenseQueries.

3. select giúp giảm re-render bằng cách nào?

Lời giải

select thu hẹp data mà component “nghe”. Component chỉ re-render khi kết quả của select đổi (so sánh cấu trúc), chứ không phải khi toàn bộ data query đổi. Kết hợp structural sharing, các phần không đổi giữ nguyên tham chiếu nên không kích hoạt re-render thừa.

4. Phân biệt error, failureReasonfailureCount. Field nào dùng được trong lúc query còn đang retry?

Lời giải

error chỉ được set khi query cạn số lần retry (status chuyển 'error'). Trong lúc đang retry, status vẫn 'pending'error vẫn null, nhưng failureReason chứa lỗi của lần thử gần nhất và failureCount đếm số lần đã thất bại — dùng hai field này để hiện “Đang thử lại…” mà chưa coi là lỗi hẳn.

5. networkMode mặc định là gì, và fetchStatus: 'paused' nghĩa là sao? Vì sao đừng hiển thị màn hình lỗi khi gặp nó?

Lời giải

Mặc định networkMode: 'online'. Khi offline, query không chạy queryFn mà chuyển fetchStatus: 'paused' và tự chạy lại khi có mạng — đây không phải lỗi (status vẫn 'pending'/'success'). Nên hiện trạng thái “ngoại tuyến, sẽ tải lại” thay vì màn hình lỗi, và giữ nguyên data cũ nếu có.

6 (nâng cao). Vì sao destructure isFetching “cho chắc” rồi không dùng lại làm component re-render nhiều hơn? Hai cách khắc phục?

Lời giải

v5 dùng tracked properties: chỉ field bạn đọc mới khiến component nghe thay đổi. Destructure isFetching đăng ký nghe field đó → mỗi lần refetch nền (isFetching bật/tắt) là một lần re-render dù bạn không render nó. Cách khắc phục: (1) chỉ destructure field thực sự render; (2) đặt notifyOnChangeProps: ['data', 'error'] để ghim cứng danh sách field cần nghe.

Nâng cao: Thêm prefetch-on-hover cho danh sách, rồi thêm ensureQueryData vào route loader của trang chi tiết. Mở Network tab và xác nhận trang chi tiết hiển thị không có spinner vì data đã được mồi sẵn.


Tóm tắt

  • Đọc lỗi đúng: isError/error chỉ “chốt” khi cạn retry; trong lúc retry dùng failureReason/failureCount. Phân biệt isLoadingError (lần đầu) vs isRefetchError (giữ data cũ).
  • Retry: mặc định query 3 lần + exponential backoff (Math.min(1000 * 2 ** attempt, 30_000)); dùng retry dạng hàm để không thử lại 4xx; thêm jitter để tránh thundering herd. Mutation mặc định không retry — chỉ bật cho endpoint idempotent.
  • networkMode ('online' \| 'always' \| 'offlineFirst') quyết định hành vi offline; fetchStatus: 'paused' không phải lỗi.
  • Lỗi nghiêm trọngthrowOnError (boolean hoặc function) đẩy lên Error Boundary + useQueryErrorResetBoundary cho nút “Thử lại”; lỗi toàn cục gom ở QueryCache.onError (dùng meta.silent tránh toast trùng).
  • useSuspenseQuery cho loading khai báo (data không-undefined, mặc định throwOnError: true) nhưng coi chừng waterfall → dùng useSuspenseQueries để chạy song song.
  • Hiệu năng: select thu hẹp subscribe, structural sharing giữ tham chiếu ổn định, tracked properties / notifyOnChangeProps cắt re-render thừa, prefetch (hover + loader ensureQueryData) cho cảm giác tức thì. Tối ưu theo thứ tự bundle → data → re-render.

Phần tiếp theo

Phần 8 — Testing & Capstone (cột mốc): test hook query/mutation bằng Vitest + React Testing Library + MSW (mock ở tầng network), kiểm thử cả optimistic rollback, rồi gom kiến thức 8 phần nền tảng đầu tiên thành một feature CRUD hoàn chỉnh — danh sách + tìm kiếm + phân trang + tạo/sửa/xoá với optimistic update. Đây là cột mốc khép lại nhóm 8 phần cơ bản, không phải điểm kết của cả series: từ Phần 9 trở đi series bước sang các chủ đề nâng cao/production (QueryClient & defaults sâu, SSR & hydration, realtime, type-safety đỉnh cao, kiến trúc & migration). Đọc tiếp: Phần 8 — Testing & Capstone.