jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TanStack Query · Phần 11 — Prefetching & tích hợp Router nâng cao

Tải data trước theo ý định người dùng: prefetch on hover/focus/viewport, ensureQueryData vs prefetchQuery, prefetchInfiniteQuery, route loader (React Router & TanStack Router) và cách triệt tiêu request waterfall.

Phần 7 đã giới thiệu prefetch-on-hover và ensureQueryData trong route loader. Phần này đào sâu toàn bộ nghệ thuật prefetch — vũ khí mạnh nhất để app cảm giác tức thì mà không cần server nhanh hơn. Ý tưởng cốt lõi: con người để lộ ý định trước khi hành động (rê chuột, focus, cuộn tới gần), và ta dùng những tín hiệu đó để nạp data sớm.

Ta cũng mổ xẻ kẻ thù số một của tốc độ cảm nhận: request waterfall — chuỗi request nối đuôi tuần tự khi đáng lẽ chúng chạy song song. Và ta sẽ thấy router (React Router v7, TanStack Router) biến prefetch thành một phần kiến trúc chứ không phải mẹo lẻ tẻ.


1. Tại sao prefetch? Mô hình “ý định người dùng”

Fetch phản ứng (reactive) là: component mount → useQuery gọi mạng → spinner → data về. Người dùng chờ toàn bộ chuỗi đó sau khi đã click. Prefetch dịch chuyển việc fetch lên trước thời điểm click, dùng các tín hiệu ý định.

REACTIVE (mặc định):
  click ──▶ mount ──▶ fetch ════════▶ data ──▶ render
                       └─ user chờ ở đây (spinner) ─┘

PREFETCH on hover:
  hover ──▶ fetch ════════▶ (data nằm sẵn trong cache)
                  ...user còn đang rê chuột...
  click ──▶ mount ──▶ useQuery đọc cache ──▶ render NGAY
                       └─ 0ms chờ ─┘

Khoảng thời gian giữa “hover” và “click” thường 200–500ms — đủ để một request bình thường hoàn tất. Prefetch mua khoảng trống đó.

Prefetch không làm server nhanh hơn — nó làm app cảm giác nhanh hơn. Đây là tối ưu perceived performance: ta không rút ngắn fetch, ta dịch nó ra khỏi đường tới hạn (critical path) của tương tác.


2. Bốn API prefetch — bảng tra cứu

QueryClient cung cấp một họ phương thức để nạp data ngoài render. Bảng dưới là kim chỉ nam:

APITrả vềThrow khi lỗi?Dùng cache fresh?Dùng khi
prefetchQuery(opts)Promise<void>Không (nuốt lỗi)Mồi cache “best effort”, không chặn UI
ensureQueryData(opts)Promise<TData>Cần data ngay (loader) — trả cache nếu fresh, fetch nếu chưa
fetchQuery(opts)Promise<TData>Cần data + muốn bắt lỗi tường minh ngoài React
prefetchInfiniteQuery(opts)Promise<void>KhôngMồi infinite list (mục 10)
ensureInfiniteQueryData(opts)Promise<InfiniteData>Loader cho infinite list

Tất cả nhận cùng object options với useQuery (queryKey, queryFn, staleTime…), nên bạn truyền thẳng queryOptions đã định nghĩa cho component vào — không lặp key.

// queryOptions tái dùng cho cả prefetch lẫn useQuery (Phần 3):
function customerQuery(id: string) {
  return queryOptions({
    queryKey: ['customers', 'detail', id] as const,
    queryFn: () => fetchCustomer(id),
    staleTime: 60_000,
  });
}

queryClient.prefetchQuery(customerQuery(id));            // mồi nền
const c = await queryClient.ensureQueryData(customerQuery(id)); // chờ data

Một nguồn sự thật cho key. Truyền cùng customerQuery(id) vào loader và useQuery đảm bảo key trùng khít → data prefetch chính là data component đọc. Key lệch một ký tự = prefetch vô dụng (xem Gotchas, mục 12).


3. Dưới nắp: prefetch ghi thẳng vào cache

Khác biệt mấu chốt giữa prefetch và useQuery: prefetch không tạo observer. useQuery đăng ký một observer vào QueryCache (để re-render khi data đổi); prefetch chỉ kích hoạt fetch và ghi kết quả vào cache rồi thôi.

prefetchQuery(opts):
  1. Tra cache theo queryKey (hash).
  2. Có entry và còn FRESH (isStale === false)?
        ├─ Có  → return ngay (KHÔNG gọi mạng).      ← tôn trọng staleTime
        └─ Không → gọi queryFn, ghi kết quả vào cache.
  3. Resolve Promise<void>. Không observer ⇒ không
     component nào re-render vì lời gọi này.

useQuery(opts) sau đó:
  1. Mount → tạo observer → đọc cache.
  2. Data đã nằm sẵn (do prefetch) → render NGAY, không spinner.
  3. Nếu data đã stale → background refetch (vẫn show data cũ).

Hai hệ quả thực dụng:

  • Prefetch lặp lại là an toàn. Hover 10 lần lên cùng link, nếu data còn fresh thì 9 lần sau là no-op. Không cần tự debounce vì staleTime (mục 5).
  • Prefetch không giữ data sống. Vì không có observer, entry vẫn chịu gcTime. Nếu user hover rồi bỏ đi quá gcTime (mặc định 5 phút), data prefetch bị thu hồi — hành vi đúng, không rò bộ nhớ.

4. prefetchQuery vs ensureQueryData vs fetchQuery — chọn cái nào

Ba API trông giống nhau; khác biệt nằm ở giá trị trả vềxử lý lỗi:

Bạn có CẦN giá trị data trả về ngay không?
├─ KHÔNG → prefetchQuery        (mồi nền, fire-and-forget, nuốt lỗi)
└─ CÓ
   └─ Bạn có muốn lỗi THROW để xử lý (loader/error boundary)?
      ├─ CÓ, trong route loader      → ensureQueryData  (trả cache nếu fresh)
      └─ CÓ, muốn LUÔN fetch tươi mới → fetchQuery       (bỏ qua cache fresh? xem dưới)

Điểm tinh tế: ensureQueryData = “đảm bảo có data” → nếu cache còn fresh, nó trả luôn không fetch. fetchQuery cũng tôn trọng staleTime, nhưng ngữ nghĩa của nó là “tôi muốn fetch này”; bạn thường ép tươi mới bằng staleTime: 0 khi cần.

// (a) Mồi nền — không quan tâm kết quả/lỗi:
queryClient.prefetchQuery(customerQuery(id));

// (b) Loader — cần data, tái dùng cache fresh, throw để router bắt lỗi:
const customer = await queryClient.ensureQueryData(customerQuery(id));

// (c) Ngoài React (vd nút "Export") — cần data + bắt lỗi thủ công:
try {
  const data = await queryClient.fetchQuery(reportQuery(id));
  download(data);
} catch (err) {
  toast.error('Tải báo cáo thất bại');
}

Quy tắc nhanh: loader dùng ensureQueryData; “mồi link” dùng prefetchQuery; thao tác mệnh lệnh ngoài render (export, generate) dùng fetchQuery.


5. staleTime & prefetch — tránh fetch thừa

Prefetch chỉ đáng giá nếu data còn fresh lúc component thật sự mount. Đây là chỗ staleTime quyết định thành bại.

Tình huốngstaleTimeKết quả khi click sau khi hover
Mặc định0Data prefetch stale ngayuseQuery refetch nền → vẫn thấy spinner-ngầm/flash
Hợp lý30_00060_000Data còn fresh khi click → render tức thì, không refetch
Quá dàiInfinityRender tức thì nhưng không bao giờ tự cập nhật → phải invalidate thủ công

Với staleTime: 0 (mặc định), prefetch vẫn điền cache nên useQuery có data ngay để render, nhưng nó coi data là stale → bắn refetch nền lập tức. Bạn mất một nửa lợi ích. Đặt staleTime đủ dài để bao trọn khoảng hover→click→xem:

function customerQuery(id: string) {
  return queryOptions({
    queryKey: ['customers', 'detail', id] as const,
    queryFn: () => fetchCustomer(id),
    staleTime: 60_000, // prefetch còn giá trị trong 60s
  });
}

staleTime là “tuổi thọ” của một lần prefetch. Prefetch + staleTime: 0 ≈ chỉ làm ấm kết nối; muốn render thực sự tức thì, cho data một khoảng fresh.


6. Prefetch theo ý định: hover, focus, viewport

Ba tín hiệu “sắp cần data”, mỗi cái hợp một ngữ cảnh:

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

function CustomerRow({ customer }: { customer: Customer }) {
  const qc = useQueryClient();
  const prefetch = () => qc.prefetchQuery(customerQuery(customer.id));

  return (
    <Link
      to={`/customers/${customer.id}`}
      onMouseEnter={prefetch} // chuột: desktop
      onFocus={prefetch}      // bàn phím / accessibility: tab tới link
      onTouchStart={prefetch} // mobile: chạm trước khi nhả
    >
      {customer.name}
    </Link>
  );
}

Đừng quên onFocus. Nếu chỉ prefetch on mouseenter, người dùng bàn phím (và screen reader) không bao giờ kích hoạt prefetch. onFocus làm prefetch accessible.

Prefetch khi phần tử vào viewport

Cho danh sách dài, prefetch chi tiết của item sắp lọt vào màn hình bằng IntersectionObserver:

function PrefetchOnVisible({ id }: { id: string }) {
  const qc = useQueryClient();
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const obs = new IntersectionObserver(
      ([entry]) => {
        if (entry?.isIntersecting) {
          qc.prefetchQuery(customerQuery(id));
          obs.disconnect(); // mồi một lần là đủ
        }
      },
      { rootMargin: '200px' }, // mồi trước khi thực sự thấy
    );
    obs.observe(el);
    return () => obs.disconnect();
  }, [id, qc]);

  return <div ref={ref} />;
}

rootMargin: '200px' mở rộng “khung quan sát” thêm 200px ra ngoài viewport → observer báo “đã thấy” trước khi item thật sự hiện ra, cho prefetch thời gian chạy trước.


7. Chống lạm dụng: debounce & điều kiện mạng

Prefetch là tối ưu, không phải bắt buộc — đừng để nó thành DDoS chính server của mình. Vài nguyên tắc:

  • Tin vào staleTime: prefetch khi data còn fresh là no-op, nên hover lặp không tạo request mới (mục 3).
  • Debounce hover nếu list dày và staleTime thấp: chỉ prefetch sau khi chuột dừng ~100ms, tránh quét chuột qua 50 dòng bắn 50 request.
  • Đừng prefetch trên mạng yếu: kiểm tra navigator.connection?.saveData hoặc effectiveType để bỏ qua khi user bật tiết kiệm dữ liệu.
function shouldPrefetch(): boolean {
  const conn = (navigator as Navigator & {
    connection?: { saveData?: boolean; effectiveType?: string };
  }).connection;
  if (conn?.saveData) return false;
  if (conn?.effectiveType && /2g/.test(conn.effectiveType)) return false;
  return true;
}

Debounce hover bằng một timer nhỏ:

function useHoverPrefetch(run: () => void, delay = 100) {
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  const onEnter = () => {
    if (!shouldPrefetch()) return;
    timer.current = setTimeout(run, delay); // chỉ chạy nếu chuột nán lại
  };
  const onLeave = () => clearTimeout(timer.current); // rời sớm → huỷ
  return { onMouseEnter: onEnter, onMouseLeave: onLeave, onFocus: run };
}

8. Route loader: triệt tiêu waterfall

Đây là nơi prefetch đáng giá nhất. Khi điều hướng bắt đầu, router có thể đồng thời tải code của route prefetch data — thay vì “render rồi mới fetch” (waterfall).

KHÔNG loader (render-then-fetch):
  navigate → tải code route ═══▶ render → useQuery fetch ═══▶ data
                                          └─ waterfall (nối đuôi) ─┘

CÓ loader (render-as-you-fetch):
  navigate ┬─ tải code route ═══▶ ┐
           └─ loader: ensureQueryData ═══▶ ┴ render với data sẵn (0 spinner)
              (code & data chạy SONG SONG)

React Router v7 (data mode)

import { queryClient } from '@/lib/query-client';
import { customerQuery } from '@/features/customers/queries';
import type { LoaderFunctionArgs } from 'react-router';

export async function customerLoader({ params }: LoaderFunctionArgs) {
  const id = params.id;
  if (!id) throw new Response('Not found', { status: 404 });
  // Mồi cache; component vẫn dùng useQuery như thường.
  await queryClient.ensureQueryData(customerQuery(id));
  return null; // không cần trả data — nó đã nằm trong cache
}
// Component không đổi — chỉ dùng useQuery, data đã sẵn trong cache:
function CustomerDetail() {
  const { id } = useParams();
  const { data } = useQuery(customerQuery(id!));
  return <h1>{data?.name}</h1>; // không spinner
}

Mấu chốt: loader không trả data cho component; nó chỉ điền cache. Component vẫn là một useQuery bình thường — nghĩa là vẫn được background refetch, invalidation, optimistic update… như mọi query khác. Loader chỉ là “người mồi sớm”.

TanStack Router (typed, tích hợp sẵn)

TanStack Router sinh ra để đi cùng React Query: loader và component chia sẻ context, key được type đầy đủ.

export const Route = createFileRoute('/customers/$id')({
  loader: ({ context: { queryClient }, params: { id } }) =>
    queryClient.ensureQueryData(customerQuery(id)),
  component: CustomerDetail,
});

function CustomerDetail() {
  const { id } = Route.useParams();
  const { data } = useQuery(customerQuery(id)); // cùng queryOptions với loader
  return <h1>{data.name}</h1>;
}

Loader nên ensureQueryData (không prefetchQuery). Loader cần chờ data để router biết khi nào điều hướng xong và có thể hiển thị error boundary nếu fetch hỏng. ensureQueryData throw khi lỗi → router bắt được. prefetchQuery nuốt lỗi → router tưởng thành công → component render với data rỗng.

Tải nhiều resource trong một loader thì luôn song song:

loader: ({ context: { queryClient }, params: { id } }) =>
  Promise.all([
    queryClient.ensureQueryData(customerQuery(id)),
    queryClient.ensureQueryData(customerOrdersQuery(id)),
  ]),

9. Render-as-you-fetch & HydrationBoundary

Pattern loader ở trên có tên: render-as-you-fetch. Triết lý: bắt đầu fetch ngay khi biết sẽ cần, không chờ component mount mới hỏi. Có ba lớp áp dụng:

TầngCơ chếKhi nào
Client navigationRoute loader gọi ensureQueryDataSPA, chuyển route trong app
IntentprefetchQuery on hover/focus/viewportTrước cả khi điều hướng
SSR / RSCPrefetch trên server + HydrationBoundaryLần tải trang đầu (Phần 10)

Với SSR (Phần 10 đã đào sâu), bạn prefetch trên server rồi dehydrate cache, gửi xuống client, và bọc cây component trong HydrationBoundary:

// Server: prefetch rồi dehydrate
const queryClient = new QueryClient();
await queryClient.prefetchQuery(customerQuery(id));

return (
  <HydrationBoundary state={dehydrate(queryClient)}>
    <CustomerDetail />
  </HydrationBoundary>
);
// Client: useQuery thấy data đã hydrate → render ngay, không fetch lại
function CustomerDetail() {
  const { data } = useQuery(customerQuery(id)); // hit cache đã hydrate
  return <h1>{data?.name}</h1>;
}

Loader + HydrationBoundary giải cùng một bài toán ở hai môi trường. Loader prefetch cho điều hướng client-side; HydrationBoundary chuyển cache đã prefetch từ server sang client. Cả hai đều biến useQuery đầu tiên thành cache-hit thay vì network-fetch.


10. prefetchInfiniteQuery cho danh sách vô hạn

Infinite list (Phần 4) cũng prefetch được — kể cả nhiều trang đầu:

await queryClient.prefetchInfiniteQuery({
  queryKey: customerKeys.infinite(),
  queryFn: fetchCustomerPage,
  initialPageParam: 0,
  getNextPageParam: (last) => last.nextCursor,
  // Mồi sẵn 2 trang đầu để cuộn xuống thấy ngay.
  pages: 2,
});

pages: 2 bảo Query nạp trước hai trang (gọi queryFn rồi dùng getNextPageParam để biết param trang kế). Hợp khi bạn biết user gần như chắc chắn sẽ cuộn (vd feed). Trong loader, dùng ensureInfiniteQueryData để vừa mồi vừa trả InfiniteData và throw khi lỗi:

loader: ({ context: { queryClient } }) =>
  queryClient.ensureInfiniteQueryData({
    queryKey: customerKeys.infinite(),
    queryFn: fetchCustomerPage,
    initialPageParam: 0,
    getNextPageParam: (last) => last.nextCursor,
  }),

Đừng pages quá tay. pages: 5 nghĩa là 5 request tuần tự (mỗi trang cần cursor của trang trước) ngay khi vào route — có thể chậm hơn cả không prefetch. Mồi 1–2 trang là đủ cho cảm giác tức thì.


11. Waterfall — nhận diện và triệt tiêu

Waterfall là khi request B chỉ bắt đầu sau khi A xong, dù B không phụ thuộc A. Dấu hiệu trong Network tab: các request xếp bậc thang thay vì song song.

WATERFALL (xấu):          SONG SONG (tốt):
  A ════════▶              A ════════▶
            B ════════▶    B ════════▶
                      C═══ C ════════▶
  tổng = A+B+C            tổng = max(A,B,C)

Waterfall do component lồng nhau

// ❌ User load xong → mới render Posts → Posts mới fetch
function Profile() {
  const { data: user } = useQuery(userQuery());
  if (!user) return <Spinner />;
  return <Posts userId={user.id} />; // posts chỉ fetch sau khi user xong
}

Nếu posts chỉ cần userId (mà bạn có sẵn từ route param), hãy fetch song song:

// ✅ Cả hai chạy cùng lúc
function Profile({ userId }: { userId: string }) {
  const user = useQuery(userQuery(userId));
  const posts = useQuery(postsQuery(userId)); // không chờ user
  // ...
}

Waterfall do useSuspenseQuery lồng nhau

useSuspenseQuery suspend component cha → chặn render con → con chưa kịp gọi query. Khắc phục: gọi các suspense query cạnh nhau hoặc dùng useSuspenseQueries:

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

function Dashboard() {
  // Cả hai khởi chạy đồng thời, cùng suspend một lần.
  const [{ data: user }, { data: stats }] = useSuspenseQueries({
    queries: [userQuery(), statsQuery()],
  });
  return <Header user={user} stats={stats} />;
}

Waterfall do prefetch tuần tự trong loader

// ❌ chờ A rồi mới mồi B
await queryClient.ensureQueryData(aQuery());
await queryClient.ensureQueryData(bQuery());

// ✅ song song
await Promise.all([
  queryClient.ensureQueryData(aQuery()),
  queryClient.ensureQueryData(bQuery()),
]);

Chỉ giữ tuần tự khi thật sự phụ thuộc. Nếu bQuery cần id từ kết quả của aQuery, waterfall là bắt buộc — nhưng hãy chắc đó là phụ thuộc dữ liệu thật, không phải thói quen viết await nối tiếp.


12. Gotchas thường gặp

Vấn đềTriệu chứngKhắc phục
Loader dùng prefetchQuery thay vì ensureQueryDataLỗi bị nuốt, component render data rỗng, không vào error boundaryDùng ensureQueryData/fetchQuery trong loader để throw
Key lệch giữa loader và componentPrefetch chạy nhưng useQuery vẫn spinner (cache-miss)Tái dùng cùng queryOptions cho cả hai nơi
staleTime: 0 (mặc định)Prefetch xong nhưng click vẫn refetch nềnĐặt staleTime đủ dài (mục 5)
Quên await/return trong loaderRouter không chờ → render trước khi data vềawait (hoặc return thẳng Promise) trong loader
await tuần tự nhiều prefetchWaterfall trong loaderGói bằng Promise.all
Over-prefetch (mồi mọi link/mọi trang)Mạng quá tải, lãng phí băng thông, server nóngDebounce, tin staleTime, bỏ qua saveData, giới hạn pages
Chỉ onMouseEnterNgười dùng bàn phím/mobile không bao giờ prefetchThêm onFocus + onTouchStart
Prefetch nhưng gcTime ngắnData bị thu hồi trước khi click (hover sớm rồi rời lâu)Tăng gcTime nếu khoảng hover→click có thể dài

13. Recipes thực chiến

import { useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router';

function PrefetchLink({ id, children }: { id: string; children: React.ReactNode }) {
  const qc = useQueryClient();
  const prefetch = () => {
    if (!shouldPrefetch()) return;
    qc.prefetchQuery(customerQuery(id)); // no-op nếu còn fresh
  };
  return (
    <Link
      to={`/customers/${id}`}
      onMouseEnter={prefetch}
      onFocus={prefetch}
      onTouchStart={prefetch}
    >
      {children}
    </Link>
  );
}

Route loader chuẩn (song song + ensureQueryData)

export const Route = createFileRoute('/customers/$id')({
  loader: ({ context: { queryClient }, params: { id } }) =>
    Promise.all([
      queryClient.ensureQueryData(customerQuery(id)),
      queryClient.ensureQueryData(customerOrdersQuery(id)),
    ]),
  // Lỗi từ ensureQueryData ⇒ router hiển thị errorComponent.
  errorComponent: ({ error }) => <ErrorPanel error={error} />,
  component: CustomerDetail,
});

14. Bài tập

1. Khác biệt cốt lõi giữa prefetchQueryensureQueryData, và vì sao loader nên dùng ensureQueryData?

Lời giải

prefetchQuery trả Promise<void>nuốt lỗi — hợp mồi nền best-effort. ensureQueryData trả data, dùng cache nếu còn fresh, và throw khi lỗi. Loader nên dùng ensureQueryData vì router cần chờ data và cần lỗi được throw để kích hoạt error boundary của route.

2. Vì sao nên thêm onFocus (không chỉ onMouseEnter) khi prefetch theo ý định?

Lời giải

Người dùng bàn phím và screen reader điều hướng bằng Tab, kích hoạt focus chứ không mouseenter. Nếu chỉ prefetch on hover, họ không bao giờ được hưởng lợi. Thêm onFocus (và onTouchStart cho mobile) làm prefetch bao phủ mọi cách tương tác.

3. Kể hai nguyên nhân gây request waterfall và cách triệt tiêu mỗi cái.

Lời giải

(1) Component lồng nhau khi con chỉ fetch sau khi cha có data — nâng data con lên fetch song song nếu nó không thực sự phụ thuộc cha. (2) useSuspenseQuery lồng nhau — cha suspend chặn con; dùng useSuspenseQueries hoặc đặt các query cạnh nhau. (Thêm: prefetch tuần tự trong loader — gói bằng Promise.all.)

4. Bạn prefetch trên hover nhưng khi click vẫn thấy một flash spinner ngắn. staleTime đang là mặc định. Chuyện gì xảy ra và sửa thế nào?

Lời giải

staleTime mặc định là 0, nên data prefetch stale ngay lập tức. Khi useQuery mount, nó có data trong cache (render được) nhưng coi là stale → bắn background refetch → gây flash/loading ngầm. Sửa bằng cách đặt staleTime đủ dài (vd 60_000) để data còn fresh trong khoảng hover→click→xem, khi đó useQuery không refetch.

5. Trong một loader bạn viết await queryClient.ensureQueryData(aQuery()); await queryClient.ensureQueryData(bQuery()); và route load chậm gấp đôi mong đợi. bQuery không dùng kết quả của aQuery. Sửa ra sao và vì sao nhanh hơn?

Lời giải

Hai await nối tiếp tạo waterfall: bQuery chỉ bắt đầu sau khi aQuery xong, dù không phụ thuộc. Gói bằng Promise.all([ensureQueryData(aQuery()), ensureQueryData(bQuery())]) để chạy song song → tổng thời gian là max(a, b) thay vì a + b. Chỉ giữ tuần tự khi bQuery thật sự cần dữ liệu từ aQuery.

Nâng cao: Thêm prefetch-on-viewport cho một list dài, rồi mở Network tab cuộn chậm — xác nhận chi tiết item được nạp trước khi item lọt vào màn hình, và mở trang chi tiết hiển thị không spinner. Sau đó hạ staleTime về 0 và quan sát request refetch nền xuất hiện ngay sau khi mở chi tiết.


Tóm tắt

  • Prefetch = perceived performance: dịch fetch ra khỏi đường tới hạn của tương tác bằng tín hiệu ý định (hover/focus/viewport), không làm server nhanh hơn.
  • Bốn API: prefetchQuery (void, nuốt lỗi, mồi nền), ensureQueryData (trả data, dùng cache fresh, throw — cho loader), fetchQuery (data + throw, thao tác mệnh lệnh), prefetchInfiniteQuery/ensureInfiniteQueryData (infinite). Tất cả tôn trọng staleTime.
  • Dưới nắp: prefetch không tạo observer — chỉ ghi cache. Lặp lại an toàn; vẫn chịu gcTime.
  • staleTime là tuổi thọ của prefetch: 0 (mặc định) khiến click vẫn refetch nền; đặt 30–60s để render thật sự tức thì.
  • Route loader nạp data song song với code (render-as-you-fetch); dùng ensureQueryData để router chờ và bắt lỗi (React Router v7 & TanStack Router). Component vẫn là useQuery với cùng queryOptions.
  • HydrationBoundary là phiên bản SSR của cùng ý tưởng: prefetch trên server → hydrate → useQuery đầu tiên là cache-hit.
  • Waterfall đến từ component lồng, suspense lồng, và prefetch tuần tự — triệt tiêu bằng fetch song song, useSuspenseQueries, và Promise.all.

Phần tiếp theo

Phần 12 — QueryClient như một store: thao tác cache chủ động: dùng setQueryData/getQueriesData/setQueriesData để đọc-ghi cache trực tiếp, invalidate bằng predicaterefetchType, cancelQueries/removeQueries, rồi đăng ký queryClient.subscribe để đồng bộ chéo nhiều query mà không cần refetch.