TanStack Query · Phần 2 — useQuery sâu & vòng đời cache
Phân biệt status vs fetchStatus, xử lý đầy đủ loading/error/empty, hiểu chính xác staleTime vs gcTime, các trigger refetch, và đi qua vòng đời một cache entry theo dòng thời gian.
Ở Phần 1 ta đã viết query đầu tiên. Phần này mổ xẻ useQuery cho kỹ: hai trục trạng thái mà người mới hay nhầm (status vs fetchStatus), cách render đúng cả ba ca loading/error/empty, và — quan trọng nhất — hai bộ đếm staleTime và gcTime quyết định khi nào Query gọi lại mạng và khi nào nó xoá cache.
Hiểu chắc phần này, bạn sẽ thôi “đoán mò” vì sao query lúc refetch lúc không.
1. Giá trị useQuery trả về
useQuery trả về một object lớn. Những trường hay dùng nhất:
const {
data, // dữ liệu (undefined cho tới khi có lần fetch thành công đầu tiên)
error, // lỗi (null nếu không lỗi)
status, // 'pending' | 'error' | 'success' → nói về DATA
fetchStatus, // 'fetching' | 'paused' | 'idle' → nói về NETWORK
isPending, // status === 'pending'
isError, // status === 'error'
isSuccess, // status === 'success'
isFetching, // fetchStatus === 'fetching'
isLoading, // isPending && isFetching (lần fetch đầu, chưa có cache)
refetch, // gọi lại query thủ công
dataUpdatedAt, // timestamp lần data cập nhật gần nhất
} = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
Bảng tra nhanh các trường hay gặp (còn nhiều cờ phụ, nhưng đây là nhóm dùng 90% thời gian):
| Trường | Kiểu | Ý nghĩa |
|---|---|---|
data | TData | undefined | Dữ liệu; undefined cho tới lần fetch thành công đầu (trừ khi có initialData/placeholderData). |
error | TError | null | Lỗi lần fetch gần nhất; null khi không lỗi. |
status | 'pending' | 'error' | 'success' | Trục DATA — đã có data chưa. |
fetchStatus | 'fetching' | 'paused' | 'idle' | Trục NETWORK — có đang gọi mạng không. |
isPending | boolean | status === 'pending' — chưa có data. |
isSuccess | boolean | status === 'success'. |
isError | boolean | status === 'error'. |
isFetching | boolean | fetchStatus === 'fetching' — đang chạy queryFn (kể cả refetch nền). |
isLoading | boolean | isPending && isFetching — fetch lần đầu, chưa có cache → spinner toàn trang. |
isRefetching | boolean | isFetching && !isPending — refetch khi đã có data. |
isStale | boolean | Data đã quá staleTime. |
isPlaceholderData | boolean | data đang là placeholder, chưa phải data thật. |
isFetched | boolean | Query đã fetch ít nhất một lần. |
isFetchedAfterMount | boolean | Đã fetch sau lần mount hiện tại (phân biệt cache cũ với data mới). |
refetch | () => Promise<…> | Gọi lại query thủ công. |
dataUpdatedAt | number | Timestamp (ms) lần data cập nhật thành công gần nhất. |
errorUpdatedAt | number | Timestamp lần error gần nhất. |
failureCount | number | Số lần thất bại liên tiếp của lần fetch hiện tại (reset khi thành công). |
failureReason | TError | null | Lý do thất bại gần nhất trong khi đang retry. |
isLoadingđược định nghĩa lại từ v4: nay =isPending && isFetching, tức thật sự đang tải lần đầu. Đừng dùngisLoadingđể gate refetch nền (sẽ che mất data cũ) — việc đó là củaisFetching.
2. status vs fetchStatus — hai trục độc lập
Đây là chỗ gây bối rối nhất với người mới. React Query tách trạng thái thành hai câu hỏi khác nhau:
statustrả lời: “Tôi đã có dữ liệu chưa?”pending— chưa có data nào trong cache.success— đã có data.error— lần fetch thất bại và chưa có data để hiển thị.
fetchStatustrả lời: “Ngay lúc này có đang gọi mạng không?”fetching—queryFnđang chạy.idle— không làm gì.paused— muốn fetch nhưng không có mạng (offline).
Vì sao cần tách? Vì một query đã có data (status: 'success') vẫn đang refetch nền (fetchStatus: 'fetching') cùng lúc. Nếu chỉ có một trục, bạn không thể vừa hiện dữ liệu cũ vừa báo “đang làm mới”.
| Tình huống | status | fetchStatus |
|---|---|---|
| Lần đầu vào, chưa có cache | pending | fetching |
| Có cache, đang refetch nền | success | fetching |
| Có cache, đứng yên | success | idle |
| Lỗi, chưa có data | error | idle |
| Muốn fetch nhưng offline | pending | paused |
Nhìn theo lưới 2 chiều sẽ thấy hai trục thật sự độc lập — mỗi ô là một trạng thái có thật:
fetchStatus: fetching | fetchStatus: idle | fetchStatus: paused | |
|---|---|---|---|
status: pending | Tải lần đầu, chưa có data → isLoading | enabled: false, chưa từng chạy | Lần đầu nhưng offline → chờ mạng |
status: success | Có data + refetch nền → isRefetching | Có data, đứng yên (nghỉ) | Có data cũ, muốn refetch nhưng offline |
status: error | Có lỗi cũ + đang thử lại | Lỗi, đứng yên | Muốn thử lại nhưng offline |
Hệ quả thực dụng:
isLoading=isPending && isFetching→ đúng nghĩa “spinner toàn trang lần đầu”.isFetching→ dùng cho indicator nhỏ kiểu “đang làm mới…” trong khi vẫn hiện data cũ.
function UserList() {
const { data, isPending, isError, error, isFetching } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
if (isPending) return <FullPageSpinner />; // chưa có data → spinner to
if (isError) return <ErrorState message={error.message} />;
return (
<div>
{/* có data rồi nhưng đang refetch nền → chỉ báo nhẹ, không che nội dung */}
{isFetching ? <span className="text-muted">Đang làm mới…</span> : null}
<ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
</div>
);
}
3. Luôn xử lý đủ bốn ca: loading / error / empty / data
Một lỗi rất phổ biến là quên ca empty (request thành công nhưng mảng rỗng). Thứ tự kiểm tra nên là:
function CustomerList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ['customers'],
queryFn: fetchCustomers,
});
if (isPending) return <ListSkeleton />;
if (isError) return <ErrorState message={error.message} onRetry={() => location.reload()} />;
if (data.length === 0) return <EmptyState label="Chưa có khách hàng nào" />;
return <CustomerTable rows={data} />;
}
Vì sao đặt isPending và isError trước? Vì sau hai lệnh return đó, TypeScript thu hẹp kiểu và hiểu rằng data chắc chắn đã tồn tại — nên data.length không còn báo lỗi “possibly undefined”. Đây là cách viết vừa an toàn type vừa đúng UX.
4. staleTime vs gcTime — hai bộ đếm hay bị nhầm
Đây là khái niệm cốt lõi nhất của React Query. Hai cái tên gần giống nhau nhưng trả lời hai câu hỏi khác hẳn:
| Bộ đếm | Trả lời câu hỏi | Mặc định |
|---|---|---|
staleTime | Data được coi là tươi trong bao lâu (không refetch nền)? | 0 (luôn stale ngay) |
gcTime | Sau khi không còn component nào dùng, bao lâu thì xoá cache? | 5 phút |
Vẽ ra thành một máy trạng thái sẽ dễ nhớ — staleTime điều khiển nhánh trên (FRESH ↔ STALE), gcTime điều khiển nhánh dưới (INACTIVE → bị xoá):
fetch thành công
┌───────────┐ ───────────► ┌──────────┐
│ (chưa có) │ │ FRESH │ data mới, còn trong staleTime
└───────────┘ └────┬─────┘
│ hết staleTime
▼
refetch nền xong → FRESH lại ┌──────────┐
┌──────────────────────────► │ STALE │ vẫn hiện data, nhưng sẽ refetch
│ └────┬─────┘ khi mount / focus / reconnect
│ │ unmount → không còn observer
│ ▼
│ ┌──────────┐
└── mount lại trong gcTime │ INACTIVE │ không ai dùng; bắt đầu đếm gcTime
(data quay lại UI) └────┬─────┘
│ hết gcTime mà vẫn không ai dùng
▼
┌───────────────────┐
│ GARBAGE-COLLECTED │ cache entry bị xoá hẳn
└───────────────────┘
Đặt cạnh nhau cho rõ — hai bộ đếm đo hai pha khác nhau của vòng đời:
staleTime | gcTime | |
|---|---|---|
| Điều khiển | Khi nào refetch | Khi nào xoá cache |
| Đếm trong pha | Query active (có observer) | Query inactive (hết observer) |
| Mặc định | 0 (stale ngay) | 5 * 60_000 (5 phút) |
| Hết giờ thì | Data thành stale → refetch ở trigger kế | Cache entry bị GC |
| Nên đặt cao khi | Data ít đổi → bớt request | Muốn quay lại trang không thấy màn trống |
Nói cách khác:
staleTimeđiều khiển việc REFETCH. Trong khoảng tươi, Query phục vụ data từ cache mà không gọi mạng. Hết khoảng đó, data thành “stale” và Query sẽ refetch nền ở các thời điểm thích hợp.gcTimeđiều khiển BỘ NHỚ. Khi một query không còn observer nào (không component nào đang dùng), nó thành “inactive”. SaugcTime, cache entry bị garbage-collected (xoá). (gc= garbage collection; ở v4 tên cũ làcacheTime.)
Điểm mấu chốt: một query có thể vừa stale vừa vẫn còn trong cache. Khi đó Query hiện data cũ ngay lập tức, rồi mới revalidate nền — đây chính là trải nghiệm “instant rồi tự cập nhật” mà useEffect không cho miễn phí.
// fresh trong 30s, giữ cache 10 phút sau khi không còn ai dùng
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
staleTime: 30_000,
gcTime: 10 * 60_000,
});
Cách chọn
staleTime: data ít đổi (cấu hình, hồ sơ) → đặtstaleTimecao (vài phút đếnInfinity) để khỏi refetch thừa. Data đổi liên tục (giá realtime, tồn kho) → để thấp hoặc0. Đặt mặc định hợp lý ởQueryClient(Phần 1), rồi override từng query khi cần.
5. Cái gì kích hoạt một lần refetch?
Khi một query đang stale, React Query có thể refetch nền ở nhiều thời điểm. Bảng đầy đủ các trigger và option điều khiển từng cái:
| Trigger | Khi nào xảy ra | Option điều khiển | Mặc định |
|---|---|---|---|
| Mount | Component dùng query mount, hoặc queryKey đổi | refetchOnMount | true |
| Window focus | User quay lại tab / cửa sổ | refetchOnWindowFocus | true |
| Reconnect | Mạng online trở lại | refetchOnReconnect | true |
| Interval | Định kỳ theo thời gian (mục 8) | refetchInterval | false |
| Thủ công | Gọi refetch() | — (luôn chạy) | — |
| Invalidation | queryClient.invalidateQueries() (Phần 5) | — (đánh dấu stale + refetch query active) | — |
Ba option refetchOnMount / refetchOnWindowFocus / refetchOnReconnect nhận ba giá trị, không chỉ bật/tắt:
| Giá trị | Ý nghĩa |
|---|---|
true (mặc định) | Refetch chỉ khi data đang stale |
false | Không bao giờ tự refetch ở trigger này |
'always' | Refetch mỗi lần trigger, kể cả khi còn fresh |
Mấu chốt: nếu data còn fresh (trong staleTime), giá trị true bỏ qua mọi trigger — không tốn request. Đó chính là lý do staleTime là cần gạt cân bằng giữa “data mới” và “đỡ tốn mạng”. Chỉ 'always' mới vượt qua được hàng rào fresh.
useQuery({
queryKey: ['settings'],
queryFn: fetchSettings,
staleTime: Infinity, // không bao giờ tự coi là stale
refetchOnWindowFocus: false, // không refetch khi focus lại tab
refetchOnReconnect: 'always', // nhưng luôn làm mới khi vừa có mạng lại
});
Mỗi
useQueryđang mount trỏ vào cùng key được tính là một observer. Khi observer cuối unmount, query thành inactive — từ lúc đó các trigger focus/reconnect không còn áp dụng cho nó (không còn UI để cập nhật), vàgcTimebắt đầu đếm.
6. Vòng đời một cache entry theo dòng thời gian
Ghép tất cả lại, đây là hành trình của một query với staleTime: 60s, gcTime: 5 phút:
t=0 Mount <UserList> → key ['users'] chưa có cache
→ status=pending, fetchStatus=fetching → queryFn chạy
→ thành công → data vào cache, status=success, đánh dấu FRESH
t=0..60s Data FRESH. Mọi mount mới / focus tab → KHÔNG refetch, dùng cache ngay
t=10s Rời tab rồi quay lại → vẫn fresh → KHÔNG refetch
t=70s Quay lại tab → data đã STALE (>60s)
→ hiện data cũ NGAY, đồng thời refetch nền (fetchStatus=fetching)
→ response về → cache cập nhật, lại FRESH
t=200s Unmount <UserList> → không còn observer → query thành INACTIVE
→ gcTime (5 phút) bắt đầu đếm
t=200s+5p Không component nào dùng lại trong 5 phút → cache entry bị XOÁ
→ lần sau mount lại sẽ là pending + fetch từ đầu
Mở Devtools và quan sát đúng dòng thời gian này: query đổi màu fresh → stale → fetching, rồi chuyển sang “inactive” khi bạn unmount, và biến mất sau gcTime. Khi đã “thấy tận mắt”, mọi quyết định cấu hình về sau đều dễ.
7. enabled — lazy query & dependent query
enabled: false tạm hoãn query: queryFn không chạy, fetchStatus là idle, status vẫn pending (chưa có data). Đây là công cụ cho dependent query — query B cần kết quả của query A.
function useUserPosts(userId: number | undefined) {
return useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPosts(userId!),
enabled: userId !== undefined, // chỉ chạy khi đã có userId
});
}
Lưu ý: khi enabled: false, isLoading là false (không fetching) nhưng isPending là true (chưa có data). Nếu UI dùng isPending để hiện spinner, query bị tắt sẽ “kẹt spinner” — hãy gate bằng cả điều kiện enabled hoặc kiểm tra fetchStatus.
Mẹo type-safe —
skipToken. Cú phápuserId!(non-null assertion) là chỗ dễ sai. v5 cho phép truyềnskipTokenvàoqueryFnđể vừa tắt query vừa giữ kiểu an toàn:import { skipToken } from '@tanstack/react-query'; useQuery({ queryKey: ['posts', userId], queryFn: userId === undefined ? skipToken : () => fetchPosts(userId), });Trong nhánh còn lại, TS đã thu hẹp
userIdthànhnumber— không cần!.
8. refetchInterval — polling định kỳ
Truyền số mili-giây để poll đều đặn:
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 10_000, // poll mỗi 10s — hợp cho dữ liệu gần realtime
});
refetchInterval còn nhận hàm — trả false để dừng polling khi đã đạt điều kiện (rất hợp cho “đợi job xong”):
useQuery({
queryKey: ['job', jobId],
queryFn: () => fetchJob(jobId),
refetchInterval: (query) =>
query.state.data?.status === 'done' ? false : 2_000,
});
Mặc định interval tạm dừng khi tab mất focus để tiết kiệm pin/mạng. Muốn vẫn poll khi tab ẩn, bật refetchIntervalInBackground: true (chỉ dùng khi thật cần, vd dashboard luôn mở).
9. placeholderData vs initialData
Cả hai giúp tránh màn hình trống bằng cách cho query một data ban đầu, nhưng khác nhau về bản chất và ảnh hưởng tới cache:
initialData | placeholderData | |
|---|---|---|
| Bản chất | Data thật, coi như đã fetch | Data giả tạm, chỉ để lấp chỗ trống |
| Ghi vào cache? | Có — nằm trong cache entry | Không — chỉ hiện ở component này |
status | success ngay | success, kèm isPlaceholderData: true |
Tính theo staleTime? | Có — có thể không refetch nếu còn fresh | Không — query vẫn fetch nền ngay |
| Persist (Phần 13)? | Có | Không |
| Dùng khi | Bạn thật sự có data đúng (vd lấy từ list đã tải) | Chỉ cần khung tạm cho đỡ giật |
Quy tắc ngón cái: data đưa vào đúng như server sẽ trả →
initialData. Data chỉ là khung/đoán →placeholderData. Nếu nhét data đoán vàoinitialData, Query tưởng đã fresh và không refetch → user kẹt với data sai.
Lấy initialData từ một query “cha” đã có trong cache, kèm initialDataUpdatedAt để Query biết tuổi của data (nhờ vậy vẫn refetch nếu đã quá staleTime):
const queryClient = useQueryClient();
useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
initialData: () =>
queryClient.getQueryData<Todo[]>(['todos'])?.find((t) => t.id === id),
initialDataUpdatedAt: () =>
queryClient.getQueryState(['todos'])?.dataUpdatedAt,
});
keepPreviousData (giữ data trang trước). Ở v4 có option keepPreviousData: true; v5 gộp vào placeholderData qua hàm keepPreviousData:
import { keepPreviousData } from '@tanstack/react-query';
useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData, // khi đổi page, giữ data page cũ
});
Khi page đổi, key đổi → bình thường sẽ về pending (màn trắng). Với keepPreviousData, Query giữ nguyên data của key trước và đặt isPlaceholderData: true trong lúc fetch key mới — UI không nhảy. Dùng cờ này để làm mờ nhẹ kết quả cũ trong khi chờ trang mới.
10. select — biến đổi & tối ưu render
select chạy sau khi fetch, nhận data gốc và trả về thứ mà component nhìn thấy ở data. Hai lợi ích:
- Biến đổi/dẫn xuất mà không cần state phụ.
- Tối ưu render — component chỉ re-render khi output của
selectđổi, không phải khi data gốc đổi.
// Chỉ lấy mảng tên
const names = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (users) => users.map((u) => u.name),
});
// Chỉ lấy số lượng → component re-render khi length đổi, kệ phần còn lại
const count = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (users) => users.length,
});
selectchỉ đổi output ở component này; cache vẫn lưu nguyên data gốc. Hai component dùng cùng key nhưngselectkhác nhau vẫn chia sẻ một cache entry và một request. Giữ hàmselectnhẹ (nó chạy mỗi render); nếu phải tính nặng, cân nhắc memo hóa.
11. Gotchas thường gặp
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Query refetch “loạn xạ” mỗi lần focus tab | staleTime: 0 (mặc định) → data luôn stale | Đặt staleTime hợp lý, hoặc tắt refetchOnWindowFocus |
data là undefined ngay sau khi tưởng đã success | Lần đầu data luôn undefined; truy cập trước khi check | Check isPending/isError trước để TS thu hẹp kiểu |
| Spinner toàn trang nhấp nháy mỗi lần refetch | Dùng isLoading/isFetching sai chỗ | Spinner đầu: isPending; refetch nền: isFetching |
| Cache “biến mất” khi rời trang rồi quay lại | gcTime thấp → query inactive bị GC sớm | Tăng gcTime cho data muốn giữ |
| Đổi page thấy màn trắng giật | Mỗi page là key mới → về pending | placeholderData: keepPreviousData |
Dependent query chạy với tham số undefined | Quên enabled | enabled: id !== undefined, hoặc queryFn: skipToken |
| Data đoán khiến query không bao giờ refetch | Nhét data tạm vào initialData | Dùng placeholderData cho data tạm |
| Polling vẫn chạy ngốn pin khi tab ẩn | Bật nhầm refetchIntervalInBackground | Bỏ cờ đó — mặc định interval dừng khi blur |
| Hai component cùng key vẫn gọi mạng hai lần | Key không bằng nhau về cấu trúc (vd object khác thứ tự field) | Giữ key ổn định; tách thành key factory (Phần 3) |
12. Recipes thực chiến
Recipe A — Poll cho tới khi job chạy xong, rồi tự dừng.
function JobStatus({ jobId }: { jobId: string }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ['job', jobId],
queryFn: () => fetchJob(jobId),
refetchInterval: (query) =>
query.state.data?.status === 'done' ? false : 2_000,
});
if (isPending) return <Spinner />;
if (isError) return <ErrorState message={error.message} />;
return <progress value={data.progress} max={100} />;
}
Recipe B — Dependent query: lấy user theo email, rồi mới lấy posts của user.
function UserDashboard({ email }: { email: string }) {
const userQuery = useQuery({
queryKey: ['user', email],
queryFn: () => fetchUserByEmail(email),
});
const userId = userQuery.data?.id;
const postsQuery = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPostsByUser(userId!),
enabled: userId !== undefined, // chờ có userId mới chạy
});
if (userQuery.isPending) return <Spinner />;
if (userQuery.isError) return <ErrorState message={userQuery.error.message} />;
return <PostList query={postsQuery} owner={userQuery.data.name} />;
}
Recipe C — Ô tìm kiếm giữ kết quả cũ khi gõ (đỡ nhấp nháy).
function SearchResults({ term }: { term: string }) {
const { data, isPlaceholderData, isPending } = useQuery({
queryKey: ['search', term],
queryFn: () => searchApi(term),
enabled: term.length > 0,
placeholderData: keepPreviousData,
staleTime: 30_000,
});
if (term.length === 0) return <Hint text="Nhập từ khoá để tìm" />;
if (isPending) return <Spinner />;
return (
<ul className={isPlaceholderData ? 'opacity-60' : undefined}>
{data.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
);
}
13. Bài tập
1. Một query có staleTime: 60_000 và gcTime: 300_000. Bạn xem trang, rời đi 2 phút, rồi quay lại. Điều gì xảy ra?
Lời giải
Cache vẫn còn (2 phút < 5 phút gcTime) nên data cũ hiện ngay lập tức. Nhưng nó đã stale (2 phút > 60s staleTime), nên Query refetch nền và cập nhật khi response về. Người dùng thấy nội dung ngay, rồi nó tự làm mới.
2. Khác nhau giữa isLoading và isFetching là gì? Dùng cái nào cho spinner toàn trang, cái nào cho indicator “đang làm mới”?
Lời giải
isFetching = đang gọi mạng (kể cả khi đã có data cũ). isLoading = isPending && isFetching = đang fetch lần đầu và chưa có data. Dùng isLoading (hoặc isPending) cho spinner toàn trang; dùng isFetching cho indicator nhỏ “đang làm mới” khi vẫn hiển thị data cũ.
3. Vì sao cần tách status và fetchStatus thành hai trục thay vì gộp thành một enum?
Lời giải
Vì hai chiều này độc lập: một query có thể đã có data (success) mà đồng thời đang refetch nền (fetching). Gộp một trục sẽ không biểu diễn được trạng thái “có data cũ + đang làm mới”, trong khi đó lại là trạng thái phổ biến nhất của UX stale-while-revalidate.
4. Bạn đã có list ['todos'] trong cache. Trang chi tiết ['todo', id] muốn hiện item tương ứng ngay mà vẫn refetch để chắc chắn mới nhất. Nên dùng initialData hay placeholderData? Vì sao?
Lời giải
Dùng initialData (cộng initialDataUpdatedAt). Item lấy từ cache list là data thật, nên ghi vào cache entry ['todo', id] là hợp lý; nhờ initialDataUpdatedAt Query biết tuổi data và vẫn refetch nền nếu đã quá staleTime. placeholderData cũng hiện ngay nhưng không ghi vào cache và không tính theo staleTime — hợp hơn cho data chỉ là khung tạm.
5. Component dùng select: (users) => users.length. API trả về 100 user, refetch sau đó trả về 100 user khác nhưng vẫn 100 phần tử. Component có re-render vì lần refetch này không?
Lời giải
Không. select trả 100 ở cả hai lần; React Query so sánh output của select, thấy 100 === 100 nên không buộc component re-render dù data gốc đã đổi. Đây chính là phần “tối ưu render” của select. (Component khác dùng select trả về danh sách tên thì sẽ re-render.)
Nâng cao: Tạo hai component cùng dùng queryKey: ['users'] với staleTime: 10_000. Mount cả hai cùng lúc và quan sát Devtools: chỉ có một request (dedup). Đợi qua 10s, focus lại tab và xem nó refetch một lần cho cả hai.
Tóm tắt
useQuerycó hai trục trạng thái:status(đã có data chưa:pending/success/error) vàfetchStatus(có đang gọi mạng:fetching/idle/paused). Tra cứu nhanh các cờ qua bảng ở mục 1.- Luôn xử lý đủ loading → error → empty → data; đặt
isPending/isErrortrước để TypeScript thu hẹp kiểudata. staleTime= data tươi bao lâu (điều khiển refetch, đếm khi active);gcTime= giữ cache bao lâu sau khi hết observer (điều khiển bộ nhớ, đếm khi inactive). Một query có thể stale mà vẫn còn cache.- Trigger refetch gồm mount / focus / reconnect / interval / invalidation; ba option
refetchOn*nhậntrue | false | 'always'và đều bị fresh-data chặn (trừ'always'). enabled(hoặcskipToken) cho lazy & dependent query;refetchInterval(số hoặc hàm) cho polling.initialDataghi vào cache và tính theostaleTime;placeholderDatachỉ lấp tạm, không ghi cache. DùngkeepPreviousDatađể khỏi giật khi đổi page/từ khoá.selectbiến đổi data và tối ưu render: component chỉ re-render khi output củaselectđổi, cache vẫn giữ data gốc.
Phần tiếp theo
Phần 3 — Query keys, zod & queryOptions: thiết kế hệ query key phân cấp chống gõ sai, viết một apiFetch dùng chung validate dữ liệu tại biên bằng zod (không bao giờ để any lọt vào app), gói query bằng queryOptions để tái dùng, và làm quen dependent/parallel queries.