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ần | Chủ đề |
|---|---|
| 1 | Mental model & cài đặt (bài này) |
| 2 | useQuery sâu, staleTime/gcTime & vòng đời cache |
| 3 | Query keys, API client (zod) & queryOptions |
| 4 | Pagination & useInfiniteQuery |
| 5 | Mutations & invalidation |
| 6 | Optimistic updates & quản lý cache |
| 7 | Error handling, retry, Suspense & performance |
| 8 | Testing & capstone CRUD (cột mốc khép nửa đầu) |
| 9 | QueryClient & defaults sâu |
| 10 | SSR, Next.js App Router & hydration |
| 11 | Prefetching & tích hợp Router nâng cao |
| 12 | QueryClient như một store: thao tác cache chủ động |
| 13 | Offline-first & persistence sâu |
| 14 | Realtime: WebSocket, SSE & cache |
| 15 | Mutation nâng cao |
| 16 | Hiệu năng render sâu |
| 17 | Type-safety đỉnh cao |
| 18 | Kiế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ôngas(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ó:
- Bất đồng bộ — lấy về cần thời gian, có thể lỗi.
- Có thể cũ (stale) — dữ liệu trên màn hình có thể đã lỗi thời so với server.
- Được chia sẻ — nhiều component cùng cần một dữ liệu, không nên fetch nhiều lần.
- 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 state | Server state |
|---|---|---|
| Ai sở hữu | Bạn (trong trình duyệt) | Server (bạn chỉ mượn) |
| Đồng bộ hay bất đồng bộ | Đồng bộ, có ngay | Bất đồng bộ, cần chờ |
| Có thể cũ (stale) không | Không | Có — luôn có thể lỗi thời |
| Ai thay đổi | Chỉ bạn | Bạn và người khác, bất kỳ lúc nào |
| Có cần revalidate không | Không | Có — khi focus tab, reconnect, sau khi ghi |
| Có thể fail không | Hầu như không | Có — mạng rớt, 4xx/5xx, timeout |
| Mức chia sẻ giữa component | Thường cục bộ | Rất hay — nên cache & dedup chung |
| Ví dụ | modal mở/đóng, tab, input, theme | danh sách đơn, hồ sơ user, số dư |
| Công cụ phù hợp | useState, useReducer, Zustand | TanStack 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/usersmount 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
QueryClient là bộ 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.
| Option | Kiểu | Mặc định | Ý nghĩa |
|---|---|---|---|
staleTime | number (ms) | 0 | Dữ 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.) |
gcTime | number (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.) |
retry | number | boolean | fn | 3 | Số lần thử lại khi queryFn throw. false = tắt, true = vô hạn, hoặc (failureCount, error) => boolean. |
retryDelay | number | fn | backoff | Chờ giữa các lần retry. Mặc định min(1000 * 2 ** n, 30_000) — luỹ thừa, trần 30s. |
refetchOnWindowFocus | boolean | 'always' | true | Refetch khi cửa sổ focus lại. 'always' = refetch cả khi còn tươi. |
refetchOnReconnect | boolean | 'always' | true | Refetch khi mạng kết nối lại. |
refetchOnMount | boolean | 'always' | true | Refetch khi observer mới mount, nếu data đã stale. |
refetchInterval | number | false | fn | false | Polling: 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. |
enabled | boolean | true | false = không tự chạy (dependent / lazy query). |
select | (data) => T | – | Biến đổi/chọn lọc data trước khi trả về component; tránh re-render thừa. |
placeholderData | T | fn | – | Data tạm trong lúc fetch (không vào cache). keepPreviousData hợp pagination. |
structuralSharing | boolean | true | Giữ nguyên tham chiếu phần data không đổi → giảm re-render. |
throwOnError | boolean | fn | false | true = ném lỗi lên Error Boundary thay vì trả qua isError (Suspense — Phần 7). |
Option ở
useQueryluôn thắng default toàn cục. Ngoàiqueries, còndefaultOptions.mutations(vdretry,networkMode,onError,onSuccess) áp chouseMutation— 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ằnguseState(() => 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ảithrowkhi 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:
queryFnphải throw khi lỗi.fetchchỉ reject khi mất mạng; với HTTP 404/500 nó vẫn “thành công”. Luôn kiểm trares.ok. (Ở Phần 3 ta sẽ gói chuyện này vào mộtapiFetchdùng chung + validate bằng zod.)- Sau khi qua
isPendingvàisError, TypeScript tự hiểudatakhông cònundefined— bạn map thẳngdatamà không cần?.hay!. isPendingvsisLoading: trong v5,isPendingnghĩa là “chưa có dữ liệu nào trong cache”, cònisLoading = isPending && isFetching(lần tải đầu, đang gọi mạng). Ta phân biệt kỹstatusvàfetchStatusngay 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:
| Field | Kiểu | Ý nghĩa |
|---|---|---|
data | TData | undefined | Dữ liệu trả về. undefined khi chưa từng fetch thành công. |
error | TError | null | Lỗ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. |
isPending | boolean | status === 'pending' — chưa có data nào. |
isSuccess | boolean | status === 'success'. |
isError | boolean | status === 'error'. |
isLoading | boolean | isPending && isFetching — lần tải đầu tiên (chưa cache, đang gọi mạng). |
isFetching | boolean | fetchStatus === 'fetching' — có request đang chạy (kể cả refetch nền khi đã có data). |
isRefetching | boolean | isFetching && !isPending — refetch khi đã có data. |
isStale | boolean | Data đã quá staleTime, lần tới sẽ refetch. |
isPlaceholderData | boolean | data hiện là placeholderData, chưa phải dữ liệu thật. |
isFetched | boolean | Query đã fetch ít nhất một lần (thành công hay lỗi). |
isFetchedAfterMount | boolean | Đã fetch sau khi observer hiện tại mount (phân biệt data cũ từ cache). |
isPaused | boolean | fetchStatus === 'paused' — muốn fetch nhưng bị tạm dừng (offline). |
dataUpdatedAt | number | Timestamp lần data cập nhật thành công gần nhất. |
errorUpdatedAt | number | Timestamp lần error cập nhật gần nhất. |
failureCount | number | Số lần fetch thất bại liên tiếp; reset về 0 khi thành công. |
failureReason | TError | null | Lỗ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:
statustrả lời “Tôi đã có dữ liệu chưa?” →'pending' | 'error' | 'success'.fetchStatustrả 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ết | Dùng cờ | Tương đương |
|---|---|---|
| Lần tải đầu tiên (chưa có gì để hiện) | isLoading | isPending && isFetching |
| Đang có data nhưng đang làm mới | isRefetching | isFetching && !isPending |
| Chưa có data, bất kể có gọi mạng | isPending | status === 'pending' |
| Có đang chạm mạng không (bất kể có data) | isFetching | fetchStatus === '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ùngisFetchingđể 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ứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Query refetch liên tục, mỗi render lại gọi mạng | Tạo new QueryClient() trong component, hoặc queryKey chứa object/array tạo mới mỗi render | Tạ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 / isError | Luô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ỗi | fetch 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 loading | Che màn hình bằng isFetching thay vì isPending | Dù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ật | staleTime quá lớn nên Query coi data còn tươi, không refetch | Hạ 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ện | Quên render <ReactQueryDevtools />, hoặc đang ở production build | Thê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.ok và throw 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.ok và throw, 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 + useStatethiếu cache, dedup, retry, refetch nền và an toàn race condition — React Query lo sẵn tất cả nhờ cache theoqueryKeyvà 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ộtQueryClientở module scope, bọc app bằngQueryClientProvider, thêm Devtools.defaultOptions.queries(đặc biệtstaleTime,gcTime,retry,refetchOnWindowFocus) đặt hành vi mặc định toàn app; override được ở từnguseQuery. useQuerycầnqueryKey(định danh cache) +queryFn(async, phảithrowkhi lỗi). Sau khi chắnisPending/isError,datađã có type chuẩn, không cần?./!.status(có data chưa) độc lập vớifetchStatus(có đang gọi mạng không). DùngisPendingđể 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.