jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TanStack Query · Phần 1 — Mental Model & Cài đặt

Mở màn series: server state là gì và vì sao nó khác client state, vì sao đừng tự viết fetch + useEffect + useState, rồi cài QueryClient + Provider + Devtools và viết useQuery đầu tiên.

Gần như mọi app React đều phải lấy dữ liệu từ server: danh sách user, chi tiết sản phẩm, thông báo… Và gần như ai cũng bắt đầu bằng useEffect + useState. Nó chạy được, nhưng rồi bạn phải tự viết lại cache, chống request trùng, retry, refetch nền, chống race condition… ở mọi màn hình. Đó chính là khoảng trống mà TanStack Query (tên cũ: React Query) lấp đầy.

Series này (18 phần) đưa bạn từ “fetch tay bằng useEffect” đến chỗ tự tin dùng React Query v5 trong dự án thật — có cache, optimistic update, SSR/hydration, realtime, persistence và kiến trúc production. Tám phần đầu là phần “lõi” ai cũng cần; mười phần sau đào sâu từng chủ đề nâng cao. Phần 1 đặt nền: tư duy đúng về server state, rồi cài đặt và viết query đầu tiên.

PhầnChủ đề
1Mental model & cài đặt (bài này)
2useQuery sâu, staleTime/gcTime & vòng đời cache
3Query keys, API client (zod) & queryOptions
4Pagination & useInfiniteQuery
5Mutations & invalidation
6Optimistic updates & quản lý cache
7Error handling, retry, Suspense & performance
8Testing & capstone CRUD (cột mốc khép nửa đầu)
9QueryClient & defaults sâu
10SSR, Next.js App Router & hydration
11Prefetching & tích hợp Router nâng cao
12QueryClient như một store: thao tác cache chủ động
13Offline-first & persistence sâu
14Realtime: WebSocket, SSE & cache
15Mutation nâng cao
16Hiệu năng render sâu
17Type-safety đỉnh cao
18Kiến trúc production & migration (capstone)

Phiên bản dùng trong series: React 19 + @tanstack/react-query v5 + TypeScript strict. Code không dùng any, không as (trừ sau khi validate runtime bằng zod).


1. Server state không phải state của bạn

Đây là bước chuyển tư duy quan trọng nhất. Trong một app, có hai loại “state” hoàn toàn khác bản chất:

  • Client state — state bạn sở hữu: modal đang mở hay đóng, tab đang chọn, giá trị input, theme dark/light. Nó sống trong bộ nhớ trình duyệt, đồng bộ, và chỉ bạn thay đổi. Công cụ: useState, useReducer, Zustand…
  • Server state — state bạn mượn: danh sách đơn hàng, hồ sơ user, số dư tài khoản. Nó sống trên server, có thể bị người khác thay đổi mà bạn không hề biết, và cái bạn cầm trên tay chỉ là một ảnh chụp (snapshot) đã được cache.

Server state có những đặc tính mà client state không có:

  1. Bất đồng bộ — lấy về cần thời gian, có thể lỗi.
  2. Có thể cũ (stale) — dữ liệu trên màn hình có thể đã lỗi thời so với server.
  3. Được chia sẻ — nhiều component cùng cần một dữ liệu, không nên fetch nhiều lần.
  4. Cần đồng bộ lại (revalidate) — khi user quay lại tab, khi mạng kết nối lại, sau khi ghi dữ liệu.

Đặt cạnh nhau cho dễ nhớ:

Tiêu chíClient stateServer state
Ai sở hữuBạn (trong trình duyệt)Server (bạn chỉ mượn)
Đồng bộ hay bất đồng bộĐồng bộ, có ngayBất đồng bộ, cần chờ
Có thể cũ (stale) khôngKhôngCó — luôn có thể lỗi thời
Ai thay đổiChỉ bạnBạn người khác, bất kỳ lúc nào
Có cần revalidate khôngKhôngCó — khi focus tab, reconnect, sau khi ghi
Có thể fail khôngHầu như khôngCó — mạng rớt, 4xx/5xx, timeout
Mức chia sẻ giữa componentThường cục bộRất hay — nên cache & dedup chung
Ví dụmodal mở/đóng, tab, input, themedanh sách đơn, hồ sơ user, số dư
Công cụ phù hợpuseState, useReducer, ZustandTanStack Query

Khi bạn cố nhét server state vào useState, bạn đang dùng sai công cụ — và phải tự tay viết lại tất cả những thứ trên. React Query được sinh ra để lo đúng phần này.

Khi nào không cần React Query? Nếu dữ liệu thuần cục bộ (theme, form đang gõ, trạng thái UI) — đó là client state, dùng useState/Zustand. Nếu bạn đã dùng một client GraphQL có cache riêng (Apollo, urql) thì nó đã lo phần server state. React Query toả sáng nhất với REST/fetch và bất kỳ nguồn dữ liệu bất đồng bộ nào chưa có lớp cache.


2. Vì sao không dùng useEffect + useState?

Cách “ngây thơ” ai cũng từng viết

function UserList() {
  const [data, setData] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    fetch('/api/users')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <Spinner />;
  if (error) return <p>Lỗi: {error.message}</p>;
  return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

Đoạn này chạy được nhưng thiếu rất nhiều thứ, và bạn sẽ phải tự thêm từng cái:

  • Không có cache — mỗi lần component mount lại là fetch lại từ đầu, kể cả vừa fetch xong 2 giây trước.
  • Không dedup (gộp request) — hai component cùng cần /api/users mount cùng lúc → hai request giống hệt nhau.
  • Không refetch nền — dữ liệu cũ cứ nằm đó mãi, không tự làm mới khi user quay lại tab.
  • Không retry — mạng chập một cái là hiện lỗi luôn, không thử lại.
  • Có race condition — nếu tham số đổi giữa chừng (vd ô search), response cũ có thể về sau response mới và đè lên dữ liệu đúng.

Cái bẫy race condition đáng sợ nhất vì nó im lặng:

// Khi `query` đổi nhanh, request cũ có thể resolve sau request mới
useEffect(() => {
  fetch(`/api/search?q=${query}`)
    .then((r) => r.json())
    .then(setResults); // ❌ không có gì đảm bảo đây là response mới nhất
}, [query]);

Diễn biến theo trục thời gian khi user gõ nhanh "a" rồi "ab":

t0  gõ "a"   → fetch(/search?q=a)  ───────────────┐ (mạng chậm)
t1  gõ "ab"  → fetch(/search?q=ab) ────┐          │
t2             response "ab" về  ───────┘ setResults("ab")  ✅ đúng
t3             response "a"  về  ───────────────────┘ setResults("a")   ❌ ĐÈ MẤT
                                                     UI hiện "a" dù input đang là "ab"

Request "a" xuất phát trước nhưng về sau, nên setResults của nó chạy cuối và ghi đè kết quả "ab". Không lỗi nào ném ra, không cảnh báo — UI chỉ lặng lẽ hiển thị sai. Đây là lỗi im lặng, rất khó tái hiện và càng khó test.

React Query chặn lỗi này từ gốc: mỗi queryKey là một query độc lập, và Query chỉ ghi vào cache kết quả của lần fetch mới nhất cho key đó. Khi query đổi "a""ab", key đổi (['search','a']['search','ab']); kết quả của request "a" thuộc về key cũ và không bao giờ chạm vào dữ liệu của key "ab". Bên dưới, mỗi lần fetch được gắn một số thứ tự tăng dần; response nào không phải lần fetch mới nhất của key sẽ bị bỏ qua.

Bạn có thể sửa thủ công bằng cờ ignore / AbortController, thêm cache bằng useRef, thêm retry bằng vòng lặp… Nhưng đến lúc đó bạn đang tự viết lại React Query, chỉ là phiên bản tệ hơn và lặp ở mọi màn hình.

React Query cho bạn cái gì “miễn phí”

Với cùng nhu cầu, bạn viết:

function UserList() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then((r) => r.json()),
  });

  if (isPending) return <Spinner />;
  if (isError) return <p>Lỗi: {error.message}</p>;
  return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

Và bạn nhận được sẵn: cache theo queryKey, dedup request trùng, refetch nền khi quay lại tab/khi reconnect, retry khi lỗi, chống race condition, và một bộ trạng thái rõ ràng (isPending/isError/data). Ít code hơn, lại nhiều tính năng hơn.


3. Cài đặt

Tài liệu: TanStack Query — Installation.

# npm
npm i @tanstack/react-query
npm i -D @tanstack/react-query-devtools

# pnpm
pnpm add @tanstack/react-query
pnpm add -D @tanstack/react-query-devtools

@tanstack/react-query là thư viện chính. @tanstack/react-query-devtools là panel debug (chỉ dùng khi dev, không vào bundle production).


4. Tạo QueryClient

QueryClientbộ não của React Query: nó giữ cache, biết query nào đang chạy, khi nào cần refetch. Toàn app chỉ cần một instance, tạo ở module scope (không tạo lại mỗi lần render).

// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Dữ liệu được coi là "tươi" trong 60s — trong khoảng này không refetch.
      // Mặc định là 0 (luôn stale). Đặt > 0 để giảm refetch thừa. (Phần 2 nói kỹ.)
      staleTime: 60_000,
      // Cache entry không còn ai dùng sẽ bị dọn sau 5 phút. (Phần 2 nói kỹ.)
      gcTime: 5 * 60_000,
      retry: 2, // thử lại 2 lần khi query lỗi (chống chập mạng nhất thời)
      refetchOnWindowFocus: true, // tự refetch khi user quay lại tab
    },
  },
});

Bảng defaultOptions.queries — những option hay chỉnh nhất

Mọi giá trị dưới đây là mặc định của v5; đặt ở đây để áp toàn app, và override được ở từng useQuery.

OptionKiểuMặc địnhÝ nghĩa
staleTimenumber (ms)0Dữ liệu được coi là “tươi” trong bao lâu. Còn tươi thì không refetch (kể cả khi mount lại / focus). 0 = stale ngay. (Phần 2.)
gcTimenumber (ms)300_000 (5’)Sau khi query không còn observer nào, cache giữ thêm bao lâu rồi bị garbage-collect. (v4 tên cũ: cacheTime.)
retrynumber | boolean | fn3Số lần thử lại khi queryFn throw. false = tắt, true = vô hạn, hoặc (failureCount, error) => boolean.
retryDelaynumber | fnbackoffChờ giữa các lần retry. Mặc định min(1000 * 2 ** n, 30_000) — luỹ thừa, trần 30s.
refetchOnWindowFocusboolean | 'always'trueRefetch khi cửa sổ focus lại. 'always' = refetch cả khi còn tươi.
refetchOnReconnectboolean | 'always'trueRefetch khi mạng kết nối lại.
refetchOnMountboolean | 'always'trueRefetch khi observer mới mount, nếu data đã stale.
refetchIntervalnumber | false | fnfalsePolling: tự refetch mỗi n ms.
networkMode'online' | 'always' | 'offlineFirst''online'Hành vi khi offline. 'online' = tạm dừng fetch khi mất mạng.
enabledbooleantruefalse = không tự chạy (dependent / lazy query).
select(data) => TBiến đổi/chọn lọc data trước khi trả về component; tránh re-render thừa.
placeholderDataT | fnData tạm trong lúc fetch (không vào cache). keepPreviousData hợp pagination.
structuralSharingbooleantrueGiữ nguyên tham chiếu phần data không đổi → giảm re-render.
throwOnErrorboolean | fnfalsetrue = ném lỗi lên Error Boundary thay vì trả qua isError (Suspense — Phần 7).

Option ở useQuery luôn thắng default toàn cục. Ngoài queries, còn defaultOptions.mutations (vd retry, networkMode, onError, onSuccess) áp cho useMutation — gặp ở Phần 5.

Tại sao không new QueryClient() ngay trong component App? Vì mỗi lần App re-render sẽ tạo client mới → mất sạch cache. Luôn tạo ở module scope, hoặc nếu bắt buộc đặt trong component thì bọc bằng useState(() => new QueryClient()).


5. Bọc app bằng QueryClientProvider

React Query đẩy queryClient xuống cây component qua Context. Đặt provider ở gốc, kèm Devtools:

// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './lib/query-client';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      {/* Devtools chỉ render ở dev; an toàn để tree-shake khỏi production build */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </StrictMode>,
);

Khi dự án lớn lên, nên tách phần “providers” ra một component riêng cho gọn và dễ test:

// src/app/providers.tsx
import type { ReactNode } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from '@/lib/query-client';

export function Providers({ children }: { children: ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

6. Query đầu tiên với useQuery

useQuery cần tối thiểu hai thứ:

  • queryKey — một mảng định danh dữ liệu này trong cache. Cùng key = cùng cache.
  • queryFn — hàm async trả về dữ liệu. Bắt buộc phải throw khi lỗi để React Query biết là thất bại.
// src/features/users/UserList.tsx
import { useQuery } from '@tanstack/react-query';

type User = { id: number; name: string };

async function fetchUsers(): Promise<User[]> {
  const res = await fetch('https://jsonplaceholder.typicode.com/users');
  // fetch KHÔNG tự throw khi HTTP 4xx/5xx — phải tự kiểm tra và throw,
  // nếu không React Query sẽ tưởng request thành công.
  if (!res.ok) throw new Error(`Request lỗi: ${res.status}`);
  return res.json();
}

export function UserList() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isPending) return <p>Đang tải…</p>;
  if (isError) return <p>Có lỗi: {error.message}</p>;

  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Vài điểm cần nhớ ngay từ bài đầu:

  • queryFn phải throw khi lỗi. fetch chỉ reject khi mất mạng; với HTTP 404/500 nó vẫn “thành công”. Luôn kiểm tra res.ok. (Ở Phần 3 ta sẽ gói chuyện này vào một apiFetch dùng chung + validate bằng zod.)
  • Sau khi qua isPendingisError, TypeScript tự hiểu data không còn undefined — bạn map thẳng data mà không cần ?. hay !.
  • isPending vs isLoading: trong v5, isPending nghĩa là “chưa có dữ liệu nào trong cache”, còn isLoading = isPending && isFetching (lần tải đầu, đang gọi mạng). Ta phân biệt kỹ statusfetchStatus ngay mục kế tiếp.

Mọi field useQuery trả về

useQuery trả về một object lớn. Bạn hiếm khi dùng hết, nhưng biết field nào tồn tại giúp bạn không phải tự tính lại trạng thái bằng tay:

FieldKiểuÝ nghĩa
dataTData | undefinedDữ liệu trả về. undefined khi chưa từng fetch thành công.
errorTError | nullLỗi của lần fetch gần nhất; null nếu không lỗi.
status'pending' | 'error' | 'success'Có dữ liệu hay chưa. pending = chưa có, success = đã có, error = hỏng và chưa có data cache.
fetchStatus'fetching' | 'paused' | 'idle'Có đang gọi mạng không. Độc lập với status.
isPendingbooleanstatus === 'pending' — chưa có data nào.
isSuccessbooleanstatus === 'success'.
isErrorbooleanstatus === 'error'.
isLoadingbooleanisPending && isFetching — lần tải đầu tiên (chưa cache, đang gọi mạng).
isFetchingbooleanfetchStatus === 'fetching' — có request đang chạy (kể cả refetch nền khi đã có data).
isRefetchingbooleanisFetching && !isPending — refetch khi đã có data.
isStalebooleanData đã quá staleTime, lần tới sẽ refetch.
isPlaceholderDatabooleandata hiện là placeholderData, chưa phải dữ liệu thật.
isFetchedbooleanQuery đã fetch ít nhất một lần (thành công hay lỗi).
isFetchedAfterMountbooleanĐã fetch sau khi observer hiện tại mount (phân biệt data cũ từ cache).
isPausedbooleanfetchStatus === 'paused' — muốn fetch nhưng bị tạm dừng (offline).
dataUpdatedAtnumberTimestamp lần data cập nhật thành công gần nhất.
errorUpdatedAtnumberTimestamp lần error cập nhật gần nhất.
failureCountnumberSố lần fetch thất bại liên tiếp; reset về 0 khi thành công.
failureReasonTError | nullLỗi của lần thất bại gần nhất trong khi đang retry.
refetch() => Promise<…>Gọi tay để refetch query này.

7. status vs fetchStatus — hai trục độc lập

Đây là điểm gây bối rối nhất cho người mới. v5 tách trạng thái query thành hai trục riêng biệt vì chúng trả lời hai câu hỏi khác nhau:

  • status trả lời “Tôi đã có dữ liệu chưa?”'pending' | 'error' | 'success'.
  • fetchStatus trả lời “Ngay lúc này có đang gọi mạng không?”'fetching' | 'paused' | 'idle'.

Vì sao tách? Vì một query đã có data (status: 'success') vẫn có thể đang refetch nền (fetchStatus: 'fetching'). Gộp chung vào một cờ “loading” sẽ khiến bạn không phân biệt được “tải lần đầu” với “làm mới khi đã có data” — và bạn sẽ nhấp nháy spinner mỗi lần refetch.

Ma trận kết hợp thường gặp:

                 fetchStatus
              fetching      idle           paused
status      ┌────────────┬──────────────┬────────────────┐
 pending    │ tải LẦN    │ chưa enable  │ offline, chờ   │
            │ ĐẦU TIÊN   │ / chờ deps   │ mạng (lần đầu)  │
            ├────────────┼──────────────┼────────────────┤
 success    │ refetch    │ rảnh, data   │ offline nhưng  │
            │ NỀN        │ đang tươi    │ đã có data      │
            ├────────────┼──────────────┼────────────────┤
 error      │ đang retry │ đã fail, hết │ offline sau    │
            │            │ retry        │ khi lỗi         │
            └────────────┴──────────────┴────────────────┘

Quy ra các cờ tiện dùng:

Muốn biếtDùng cờTương đương
Lần tải đầu tiên (chưa có gì để hiện)isLoadingisPending && isFetching
Đang có data nhưng đang làm mớiisRefetchingisFetching && !isPending
Chưa có data, bất kể có gọi mạngisPendingstatus === 'pending'
Có đang chạm mạng không (bất kể có data)isFetchingfetchStatus === 'fetching'

Quy tắc thực dụng: dùng isPending để quyết định “hiện skeleton hay hiện data”, và isFetching để hiện một indicator nhỏ (vd spinner góc) báo “đang làm mới”. Đừng dùng isFetching để chặn cả màn hình — nếu không mỗi lần refetch nền user lại thấy màn trắng dù data vẫn còn đó.

function Users() {
  const { data, isPending, isError, error, isFetching } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isPending) return <Skeleton />;          // chưa có data → skeleton
  if (isError) return <p>Lỗi: {error.message}</p>;

  return (
    <div>
      {/* đã có data; nếu đang refetch nền chỉ báo nhẹ, không che màn hình */}
      {isFetching && <span className="text-xs opacity-60">đang làm mới…</span>}
      <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
    </div>
  );
}

8. Mở Devtools để “nhìn thấy” cache

Mở app ở chế độ dev, bạn sẽ thấy logo React Query ở góc màn hình. Bấm vào để xem panel: mỗi query là một dòng kèm queryKey, trạng thái (fresh / stale / fetching / inactive), thời điểm cập nhật, và dữ liệu đang cache.

Hãy tập thói quen mở Devtools trong suốt series. Rất nhiều khái niệm trừu tượng (stale, refetch nền, invalidation) sẽ trở nên hiển nhiên khi bạn nhìn thấy cache đổi màu theo thời gian thực. Thử ngay: load trang, chuyển sang tab khác rồi quay lại — bạn sẽ thấy query chuyển sang fetching (refetch nền nhờ refetchOnWindowFocus).


9. Gotchas thường gặp

Triệu chứngNguyên nhânCách xử lý
Query refetch liên tục, mỗi render lại gọi mạngTạo new QueryClient() trong component, hoặc queryKey chứa object/array tạo mới mỗi renderTạo client ở module scope (hoặc useState(() => …)); giữ queryKey ổn định, chỉ chứa giá trị serialize được.
data lúc có lúc undefined, TS bắt ?. khắp nơiĐọc data trước khi chắn isPending / isErrorLuôn if (isPending)…; if (isError)…; trước; sau đó TS thu hẹp data về kiểu thật.
HTTP 500 nhưng Query báo success, data là HTML lỗifetch không throw với 4xx/5xx; queryFn cứ return res.json()Kiểm tra if (!res.ok) throw new Error(...) trong queryFn.
Mỗi lần rời tab rồi quay lại là màn hình chớp loadingChe màn hình bằng isFetching thay vì isPendingDùng isPending để chặn lần đầu; refetch nền chỉ báo bằng indicator nhỏ.
Đổi data trên server nhưng UI không cập nhậtstaleTime quá lớn nên Query coi data còn tươi, không refetchHạ staleTime, hoặc invalidateQueries sau khi ghi (Phần 5).
queryFn đọc biến ngoài (vd userId) nhưng key không chứa nóKey không đổi khi biến đổi → trả cache cũ của user khácĐưa mọi biến queryFn phụ thuộc vào queryKey: ['user', userId].
Devtools không hiệnQuên render <ReactQueryDevtools />, hoặc đang ở production buildThêm Devtools trong QueryClientProvider; nó tự loại khỏi production.

10. Bài tập

1. Trong cách viết “ngây thơ” với useEffect, race condition xảy ra khi nào và vì sao React Query miễn nhiễm?

Lời giải

Race condition xảy ra khi tham số (vd ô search) đổi nhanh: request cũ resolve sau request mới và setState đè lên dữ liệu mới. React Query miễn nhiễm vì mỗi queryKey khác nhau là một query riêng; khi key đổi, kết quả của request cũ thuộc về key cũ và không bao giờ ghi đè dữ liệu của key mới. Ngoài ra Query còn tự huỷ/bỏ qua kết quả lỗi thời.

2. Vì sao phải kiểm tra res.okthrow trong queryFn, thay vì cứ return res.json()?

Lời giải

fetch chỉ reject promise khi lỗi mạng. Với HTTP 404/500, promise vẫn resolve bình thường, nên nếu không tự kiểm tra res.okthrow, React Query sẽ coi đó là thành công và đưa nội dung lỗi (vd trang HTML 500) vào data. Throw thủ công giúp Query chuyển sang trạng thái error và kích hoạt retry.

3. Tại sao không nên tạo new QueryClient() bên trong component App mà không bọc gì?

Lời giải

Mỗi lần App re-render sẽ tạo một QueryClient mới, xoá sạch cache cũ → mọi query refetch lại liên tục. Hãy tạo ở module scope, hoặc nếu cần trong component thì dùng useState(() => new QueryClient()) để chỉ tạo một lần.

4. Một query đang ở status: 'success' nhưng fetchStatus: 'fetching'. Người dùng đang thấy gì, và bạn nên hiển thị loading thế nào?

Lời giải

Người dùng đang thấy data cũ (đã success nghĩa là có data trong cache) trong khi Query refetch nền. isPending lúc này là false, nên đừng che màn hình bằng spinner toàn trang. Giữ data trên màn hình và chỉ hiện một indicator nhỏ dựa trên isFetching (vd “đang làm mới…”). Spinner toàn trang chỉ dành cho isPending / isLoading (lần đầu chưa có gì).

5. Bạn đặt staleTime: 60_000 toàn cục nhưng một màn hình “tỉ giá realtime” cần luôn mới. Sửa ở đâu, và vì sao không nên hạ staleTime toàn cục?

Lời giải

Ghi đè ngay tại useQuery của màn hình đó: useQuery({ queryKey: ['fx'], queryFn, staleTime: 0, refetchInterval: 5_000 }). Option ở cấp useQuery luôn thắng defaultOptions toàn cục. Không hạ staleTime toàn cục vì sẽ khiến mọi query khác (vốn không cần realtime) refetch thừa — tốn mạng và gây nhấp nháy không cần thiết.

Nâng cao: Cài React Query vào một project Vite + React mới, fetch danh sách từ https://jsonplaceholder.typicode.com/users, mở Devtools và quan sát query đổi trạng thái khi bạn rời tab rồi quay lại.


Tóm tắt

  • Server state ≠ client state. Server state là ảnh chụp đã cache của dữ liệu sống trên server — bất đồng bộ, có thể cũ, được chia sẻ, cần revalidate. Đừng nhét nó vào useState.
  • Tự viết fetch + useEffect + useState thiếu cache, dedup, retry, refetch nền và an toàn race condition — React Query lo sẵn tất cả nhờ cache theo queryKey và quy tắc “chỉ ghi kết quả của lần fetch mới nhất”.
  • Cài @tanstack/react-query, tạo một QueryClient ở module scope, bọc app bằng QueryClientProvider, thêm Devtools. defaultOptions.queries (đặc biệt staleTime, gcTime, retry, refetchOnWindowFocus) đặt hành vi mặc định toàn app; override được ở từng useQuery.
  • useQuery cần queryKey (định danh cache) + queryFn (async, phải throw khi lỗi). Sau khi chắn isPending / isError, data đã có type chuẩn, không cần ?. / !.
  • status (có data chưa) độc lập với fetchStatus (có đang gọi mạng không). Dùng isPending để hiện skeleton lần đầu, isFetching để báo “đang làm mới” mà không che màn hình.

Phần tiếp theo

Phần 2 — useQuery sâu & vòng đời cache: đào sâu hai bộ đếm staleTime (độ tươi) vs gcTime (tuổi cache) điều khiển khi nào Query refetch và khi nào xoá cache, mổ xẻ trọn vẹn vòng đời một cache entry (fresh → stale → inactive → GC), và xử lý đầy đủ loading / error / empty cho UI thật.