jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 staleTimegcTime 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ườngKiểuÝ nghĩa
dataTData | undefinedDữ liệu; undefined cho tới lần fetch thành công đầu (trừ khi có initialData/placeholderData).
errorTError | nullLỗ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.
isPendingbooleanstatus === 'pending' — chưa có data.
isSuccessbooleanstatus === 'success'.
isErrorbooleanstatus === 'error'.
isFetchingbooleanfetchStatus === 'fetching' — đang chạy queryFn (kể cả refetch nền).
isLoadingbooleanisPending && isFetching — fetch lần đầu, chưa có cache → spinner toàn trang.
isRefetchingbooleanisFetching && !isPending — refetch khi đã có data.
isStalebooleanData đã quá staleTime.
isPlaceholderDatabooleandata đang là placeholder, chưa phải data thật.
isFetchedbooleanQuery đã fetch ít nhất một lần.
isFetchedAfterMountbooleanĐã 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.
dataUpdatedAtnumberTimestamp (ms) lần data cập nhật thành công gần nhất.
errorUpdatedAtnumberTimestamp lần error gần nhất.
failureCountnumberSố 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).
failureReasonTError | nullLý 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ùng isLoading để gate refetch nền (sẽ che mất data cũ) — việc đó là của isFetching.


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:

  • status trả 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ị.
  • fetchStatus trả lời: “Ngay lúc này có đang gọi mạng không?”
    • fetchingqueryFn đ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ốngstatusfetchStatus
Lần đầu vào, chưa có cachependingfetching
Có cache, đang refetch nềnsuccessfetching
Có cache, đứng yênsuccessidle
Lỗi, chưa có dataerroridle
Muốn fetch nhưng offlinependingpaused

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: fetchingfetchStatus: idlefetchStatus: paused
status: pendingTải lần đầu, chưa có data → isLoadingenabled: false, chưa từng chạyLần đầu nhưng offline → chờ mạng
status: successCó data + refetch nền → isRefetchingCó data, đứng yên (nghỉ)Có data cũ, muốn refetch nhưng offline
status: errorCó lỗi cũ + đang thử lạiLỗi, đứng yênMuố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 isPendingisError 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ộ đếmTrả lời câu hỏiMặc định
staleTimeData được coi là tươi trong bao lâu (không refetch nền)?0 (luôn stale ngay)
gcTimeSau 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:

staleTimegcTime
Điều khiểnKhi nào refetchKhi nào xoá cache
Đếm trong phaQuery active (có observer)Query inactive (hết observer)
Mặc định0 (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 khiData ít đổi → bớt requestMuố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”. Sau gcTime, 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ơ) → đặt staleTime cao (vài phút đến Infinity) để khỏi refetch thừa. Data đổi liên tục (giá realtime, tồn kho) → để thấp hoặc 0. Đặ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:

TriggerKhi nào xảy raOption điều khiểnMặc định
MountComponent dùng query mount, hoặc queryKey đổirefetchOnMounttrue
Window focusUser quay lại tab / cửa sổrefetchOnWindowFocustrue
ReconnectMạng online trở lạirefetchOnReconnecttrue
IntervalĐịnh kỳ theo thời gian (mục 8)refetchIntervalfalse
Thủ côngGọi refetch()— (luôn chạy)
InvalidationqueryClient.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
falseKhô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à gcTime bắ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, fetchStatusidle, 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, isLoadingfalse (không fetching) nhưng isPendingtrue (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áp userId! (non-null assertion) là chỗ dễ sai. v5 cho phép truyền skipToken vào queryFn để 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 userId thành number — 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ảnh hưởng tới cache:

initialDataplaceholderData
Bản chấtData thật, coi như đã fetchData giả tạm, chỉ để lấp chỗ trống
Ghi vào cache? — nằm trong cache entryKhông — chỉ hiện ở component này
statussuccess ngaysuccess, kèm isPlaceholderData: true
Tính theo staleTime? — có thể không refetch nếu còn freshKhông — query vẫn fetch nền ngay
Persist (Phần 13)?Không
Dùng khiBạ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ào initialData, 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:

  1. Biến đổi/dẫn xuất mà không cần state phụ.
  2. 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,
});

select chỉ đổi output ở component này; cache vẫn lưu nguyên data gốc. Hai component dùng cùng key nhưng select khác nhau vẫn chia sẻ một cache entry và một request. Giữ hàm select nhẹ (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ứngNguyên nhânCách xử lý
Query refetch “loạn xạ” mỗi lần focus tabstaleTime: 0 (mặc định) → data luôn staleĐặt staleTime hợp lý, hoặc tắt refetchOnWindowFocus
dataundefined ngay sau khi tưởng đã successLần đầu data luôn undefined; truy cập trước khi checkCheck isPending/isError trước để TS thu hẹp kiểu
Spinner toàn trang nhấp nháy mỗi lần refetchDù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ạigcTime thấp → query inactive bị GC sớmTăng gcTime cho data muốn giữ
Đổi page thấy màn trắng giậtMỗi page là key mới → về pendingplaceholderData: keepPreviousData
Dependent query chạy với tham số undefinedQuên enabledenabled: id !== undefined, hoặc queryFn: skipToken
Data đoán khiến query không bao giờ refetchNhét data tạm vào initialDataDùng placeholderData cho data tạm
Polling vẫn chạy ngốn pin khi tab ẩnBật nhầm refetchIntervalInBackgroundBỏ cờ đó — mặc định interval dừng khi blur
Hai component cùng key vẫn gọi mạng hai lầnKey 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_000gcTime: 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 isLoadingisFetching 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 statusfetchStatus 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

  • useQuery có 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/isError trước để TypeScript thu hẹp kiểu data.
  • 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ận true | false | 'always' và đều bị fresh-data chặn (trừ 'always').
  • enabled (hoặc skipToken) cho lazy & dependent query; refetchInterval (số hoặc hàm) cho polling.
  • initialData ghi vào cache và tính theo staleTime; placeholderData chỉ lấp tạm, không ghi cache. Dùng keepPreviousData để khỏi giật khi đổi page/từ khoá.
  • select biến đổi data và tối ưu render: component chỉ re-render khi output của select đổ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.