TanStack Query · Phần 10 — SSR, Next.js App Router & Hydration
Chạy React Query trên server: prefetch rồi truyền cache xuống client bằng dehydrate + HydrationBoundary, dùng QueryClient đúng cách trong React Server Components, và streaming data với gói next-experimental.
Tới giờ mọi query đều chạy trong trình duyệt: trang trắng → spinner → data. Với app cần SEO hoặc first paint nhanh, bạn muốn server gửi HTML đã có data sẵn. Phần này ghép React Query với server rendering — trọng tâm là Next.js App Router (mô hình phổ biến nhất 2026), nhưng nguyên lý đúng cho mọi SSR.
Mấu chốt là một vòng tròn: server fetch → “đông cứng” (dehydrate) cache thành JSON → gửi xuống client → client “rã đông” (hydrate) vào QueryClient của nó. Sau đó client tiếp quản với đầy đủ cache, refetch, optimistic update như thường.
Nhắc lại từ Phần 9: server tạo
QueryClientmới mỗi request, browser dùng singleton. Quy tắc này là nền của cả phần này.
1. Vì sao không “dùng useQuery trên server là xong”?
useQuery chỉ trả data khi queryFn resolve — nhưng server render là đồng bộ một nhịp: nó render component ra HTML ngay, không chờ promise. Nếu chỉ gọi useQuery, server xuất HTML ở trạng thái isPending (spinner), và data chỉ về sau khi client chạy. Mất luôn lợi ích SSR.
Giải pháp: prefetch trước khi render. Ta nạp data vào một QueryClient phía server trước, rồi dehydrate cache đó thành JSON, nhúng vào HTML, để client hydrate lại — useQuery mở mắt ra đã thấy data nằm sẵn trong cache.
┌── SERVER (chạy 1 lần mỗi request) ──────┐
│ const qc = getQueryClient() // mới │
│ await qc.prefetchQuery(opts) // nạp data │
│ const state = dehydrate(qc) // → JSON │
│ render(<HydrationBoundary state>...</>) │
└───────────────┬───────────────────────────┘
│ state đi kèm HTML qua <script> ("the wire")
▼
┌── CLIENT (chạy trong browser) ──────────┐
│ <HydrationBoundary state={dehydrated}> │
│ hydrate(queryClient, state) // rã đông │
│ <CustomerList/> → useQuery(opts) │
│ → cache HIT, render ngay, 0 spinner │
└───────────────────────────────────────────┘
Toàn bộ phần này chỉ xoay quanh ba động từ: prefetch (nạp trên server) → dehydrate (đóng băng thành JSON) → hydrate (rã đông vào client). Nắm ba động từ này là nắm cả SSR.
2. Mô hình hydration: bên trong dehydrate / hydrate
dehydrate(queryClient) không serialize cả QueryClient — nó chỉ trích ra một snapshot JSON của những query thoả shouldDehydrateQuery (mặc định: query success). Hình dạng:
type DehydratedState = {
mutations: DehydratedMutation[];
queries: {
queryHash: string; // chuỗi hash ổn định của queryKey
queryKey: QueryKey; // chính key đã dùng để prefetch
state: {
data: unknown; // payload — PHẢI serialize được
dataUpdatedAt: number; // mốc thời gian → quyết định stale sau hydrate
status: 'success' | 'pending' | 'error';
};
}[];
};
Phía client, hydrate(queryClient, state) (mà <HydrationBoundary> gọi hộ) duyệt mảng queries và nhồi từng entry vào cache nếu cache chưa có query đó hoặc bản trong cache cũ hơn (dataUpdatedAt nhỏ hơn). Vì dataUpdatedAt được giữ nguyên từ server, query hydrate vào sẽ “già” đúng bằng tuổi thật của nó — đây chính là lý do staleTime quyết định client có refetch ngay hay không (mục 5).
dehydrate() hydrate()
│ lọc theo shouldDehydrateQuery │ với mỗi entry:
│ → [{queryHash, queryKey, │ cache.build(key)
│ state:{data, dataUpdatedAt}}] │ set data + dataUpdatedAt
▼ ▼
JSON an toàn để nhúng vào HTML cache client = bản sao cache server
Chỉ data serialize được mới qua được “wire”.
Date,Map,Set,BigInt,undefinedlồng trong object, class instance… đều rụng hoặc vỡ khiJSON.stringify. Trả về POJO thuần (hoặc cấu hìnhserialize/deserializetuỳ biến — xem Gotchas).
3. Bảng tra cứu: API SSR cốt lõi
| API | Chạy ở đâu | Trả về | Dùng để |
|---|---|---|---|
prefetchQuery(opts) | Server | Promise<void> (nuốt lỗi) | Nạp data vào cache trước render; lỗi không làm sập trang |
prefetchInfiniteQuery(opts) | Server | Promise<void> | Như trên, cho useInfiniteQuery |
fetchQuery(opts) | Server/Client | Promise<TData> (throw lỗi) | Cần giá trị trả về, hoặc muốn lỗi nổi lên |
ensureQueryData(opts) | Server/Client | Promise<TData> | Như fetchQuery nhưng trả cache nếu đã fresh |
dehydrate(client, opts?) | Server | DehydratedState (JSON) | Đóng băng cache thành JSON để gửi xuống |
hydrate(client, state) | Client | void | Nạp DehydratedState vào cache client |
<HydrationBoundary state> | Client | JSX | Gọi hydrate cho cây con; an toàn lồng nhau |
prefetchQuery=fetchQuerynhưng trảvoidvà không throw. Trên server, prefetch song song nhiều query rồidehydratemột lần là pattern chuẩn. Khi cần đọc giá trị ngay trên server (vd để set<title>), dùngfetchQuery/ensureQueryData.
4. getQueryClient() an toàn cho cả hai môi trường
Đây là helper bắt buộc cho Next App Router. Server tạo mới mỗi lần; browser tái dùng:
// app/get-query-client.ts
import { QueryClient, isServer } from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// QUAN TRỌNG: staleTime > 0 để data prefetch không bị client
// refetch ngay lập tức sau khi hydrate (sẽ phí công server).
staleTime: 60_000,
},
dehydrate: {
// Mặc định chỉ dehydrate query "success". Thêm 'pending' để
// streaming các query CHƯA xong cũng truyền được xuống (mục 11).
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
},
},
});
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) return makeQueryClient();
browserQueryClient ??= makeQueryClient();
return browserQueryClient;
}
import { defaultShouldDehydrateQuery } from '@tanstack/react-query';
staleTime: 60_000không phải tuỳ chọn — nó gần như bắt buộc cho SSR. Nếu để mặc định0, mọi query vừa hydrate đã bị coi là stale và client refetch ngay → server prefetch trở nên vô nghĩa.
5. staleTime và cơ chế “double fetch” sau hydrate
Đây là cái bẫy SSR phổ biến nhất, đáng mổ xẻ kỹ. Sau khi hydrate, mỗi query mang theo dataUpdatedAt từ lúc server fetch. Khi component mount, React Query so:
isStale = (Date.now() - dataUpdatedAt) > staleTime
Nếu staleTime = 0 (mặc định): Date.now() - dataUpdatedAt luôn > 0 → isStale = true → với refetchOnMount: true (mặc định), client fetch lại ngay lập tức. Kết quả: server làm 1 request, client làm thêm 1 request nữa cho đúng data đó — “double fetch”, lãng phí và gây nháy.
staleTime = 0 staleTime = 60_000
───────────── ──────────────────
server fetch ✔ (req #1) server fetch ✔ (req #1)
hydrate → isStale = true hydrate → isStale = false
client mount → REFETCH (req #2) ✗ client mount → dùng cache, 0 request ✔
→ màn hình nháy data → mượt, đúng kỳ vọng SSR
staleTime | Sau hydrate | Hệ quả |
|---|---|---|
0 (mặc định) | Refetch ngay khi mount | Phí 1 request, có thể nháy UI |
> 0 (vd 60_000) | Dùng data hydrate, không fetch | Đúng tinh thần SSR |
Infinity | Không bao giờ tự refetch | Tốt cho data tĩnh; nhớ invalidate thủ công khi cần |
Quy tắc: đặt
staleTimeđủ lớn để vượt qua quãng hydrate (vài giây là đủ). Nhiều team dùng60_000làm mặc định toàn cục rồi override per-query.
6. Provider phía client
App Router cần một Client Component bọc QueryClientProvider. Lưu ý dùng getQueryClient() chứ không new QueryClient() trực tiếp trong component:
// app/providers.tsx
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { getQueryClient } from './get-query-client';
export function Providers({ children }: { children: React.ReactNode }) {
// getQueryClient() lo việc server-mới / browser-singleton.
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="vi">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
7. Prefetch trong Server Component + HydrationBoundary
Trong một Server Component (mặc định ở App Router), ta prefetch rồi bọc cây con bằng <HydrationBoundary> với state đã dehydrate:
// app/customers/page.tsx (Server Component — không có 'use client')
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '../get-query-client';
import { customersQuery } from '@/features/customers/queries';
import { CustomerList } from '@/features/customers/CustomerList';
export default async function CustomersPage() {
const queryClient = getQueryClient();
// Prefetch trên server — await để cache có data trước khi dehydrate.
await queryClient.prefetchQuery(customersQuery());
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{/* CustomerList là Client Component dùng useQuery(customersQuery()) */}
<CustomerList />
</HydrationBoundary>
);
}
CustomerList vẫn là Client Component dùng useQuery y hệt như app client-only — không cần biết data tới từ server. Đó là vẻ đẹp của mô hình: cùng một queryOptions (Phần 3) chạy được ở cả hai phía.
Prefetch song song để tránh waterfall. Nếu trang cần nhiều query, đừng await tuần tự (mỗi await chặn cái sau). Khởi động hết rồi await một lần:
const queryClient = getQueryClient();
await Promise.all([
queryClient.prefetchQuery(customersQuery()),
queryClient.prefetchQuery(statsQuery()),
]);
// hoặc: không await gì cả + dùng streamed hydration (mục 11)
prefetchQuerykhông throw — nếu một query lỗi, trang vẫn render (query đó sẽ ở trạng thái error phía client). Cần lỗi làm sập build/route thì dùngfetchQuery.
// features/customers/CustomerList.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { customersQuery } from './queries';
export function CustomerList() {
// Khi hydrate, data đã nằm trong cache → render ngay, không spinner.
const { data } = useQuery(customersQuery());
return <ul>{data?.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}
8. Prefetch danh sách vô hạn với prefetchInfiniteQuery
useInfiniteQuery (Phần 4) cũng hydrate được — chỉ cần prefetch bằng prefetchInfiniteQuery. Mặc định nó chỉ nạp trang đầu; muốn nạp sẵn vài trang thì truyền pages:
// app/feed/page.tsx (Server Component)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '../get-query-client';
import { feedQuery } from '@/features/feed/queries';
import { Feed } from '@/features/feed/Feed';
export default async function FeedPage() {
const queryClient = getQueryClient();
await queryClient.prefetchInfiniteQuery({
...feedQuery(),
pages: 1, // nạp sẵn 1 trang; tăng lên để SEO sâu hơn
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Feed />
</HydrationBoundary>
);
}
Phía client, useInfiniteQuery(feedQuery()) hydrate khớp cấu trúc { pages, pageParams } — người dùng thấy trang đầu ngay, cuộn xuống mới fetch tiếp. Lưu ý feedQuery() phải khai báo initialPageParam và getNextPageParam để cấu trúc trùng khớp hai phía.
Cạm bẫy: đừng prefetch quá nhiều trang chỉ để “cho chắc”.
pages: 3trên server nghĩa là 3 request ngay lúc SSR, làm chậm first paint; cân nhắc SEO đáng bao nhiêu trang.
9. queryOptions dùng chung — chìa khoá tránh lệch key
Đừng định nghĩa queryKey/queryFn hai lần (một cho server, một cho client) — rất dễ lệch và hydrate trượt. Khai báo một queryOptions rồi import ở cả hai phía:
// features/customers/queries.ts
import { queryOptions } from '@tanstack/react-query';
import { fetchCustomers } from './api';
import { customerKeys } from './keys';
export const customersQuery = () =>
queryOptions({
queryKey: customerKeys.lists(),
queryFn: fetchCustomers,
});
Server gọi prefetchQuery(customersQuery()), client gọi useQuery(customersQuery()) — cùng key, cùng fn, hydrate khớp tuyệt đối. Nếu key lệch dù chỉ một ký tự, client sẽ coi là query khác và fetch lại.
10. initialData vs Hydration — chọn cái nào?
Có một con đường tắt: thay vì dehydrate/HydrationBoundary, truyền thẳng data server xuống useQuery qua initialData. Nó hoạt động, nhưng khác biệt quan trọng:
// Cách initialData: data fetch ở server (vd RSC), truyền qua props
function CustomerList({ initial }: { initial: Customer[] }) {
const { data } = useQuery({ ...customersQuery(), initialData: initial });
return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}
| Tiêu chí | initialData | Hydration (dehydrate + HydrationBoundary) |
|---|---|---|
| Phạm vi | Từng query lẻ | Cả cache (nhiều query, kể cả lồng sâu) |
dataUpdatedAt | = lúc mount client (mặc định) | = lúc server fetch (giữ tuổi thật) |
| Stale sau hydrate | Coi như mới → dễ không refetch | Theo staleTime so với tuổi thật |
| Truyền data | Qua props (prop drilling) | Tự động qua <script> cache |
| Hợp với | 1-2 query đơn giản, props sẵn có | App nhiều query, mô hình chuẩn SSR |
| Rủi ro | initialData cũ vẫn ghi đè cache “mới hơn” nếu không set initialDataUpdatedAt | Ít hơn — tuổi data chính xác |
Khuyến nghị: dùng Hydration làm mặc định cho Next App Router (đúng tuổi data, không prop drilling).
initialDatachỉ tiện cho component lẻ đã có sẵn data qua props. Nếu dùnginitialData, cân nhắc truyền kèminitialDataUpdatedAtđểstaleTimetính đúng.
11. Streaming với gói next-experimental
Cách ở mục 7 chờ prefetch xong rồi mới gửi HTML (blocking). Muốn streaming — gửi shell ngay, data chảy về sau khi sẵn sàng — dùng provider chuyên dụng:
pnpm add @tanstack/react-query-next-experimental
// app/providers.tsx
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';
import { getQueryClient } from './get-query-client';
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
</QueryClientProvider>
);
}
Với provider này, bạn dùng useSuspenseQuery ngay trong cây render trên server; nó tự dehydrate từng query đang pending và stream xuống khi resolve — không cần prefetchQuery + HydrationBoundary thủ công. Đây là lý do mục 4 thêm 'pending' vào shouldDehydrateQuery.
Chọn cách nào? Prefetch +
HydrationBoundary(mục 7) cho kiểm soát rõ ràng và ổn định. Streamed hydration cho TTFB tốt hơn với data chậm, nhưng còn “experimental” — cân nhắc cho production.
12. Request-scoped QueryClient trong RSC với cache()
Helper ở mục 4 trả về client mới mỗi lần gọi trên server. Nếu nhiều Server Component trong cùng một request cùng prefetch (vd generateMetadata + page, hoặc layout + page), mỗi lần gọi getQueryClient() lại tạo client mới → data fetch trùng và dehydrate rời rạc. Nâng cấp: bọc nhánh server bằng cache() của React để một client cho mỗi request, dùng lại đúng makeQueryClient (giữ nguyên staleTime):
// app/get-query-client.ts — bản RSC dùng cache()
import { cache } from 'react';
import { QueryClient, isServer } from '@tanstack/react-query';
// makeQueryClient() như mục 4 (đã có staleTime + shouldDehydrateQuery)
// cache() (React, không phải Next): cùng request → cùng QueryClient;
// request khác → client khác → KHÔNG rò data giữa các user.
const getServerQueryClient = cache(makeQueryClient);
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) return getServerQueryClient(); // 1 client / request
browserQueryClient ??= makeQueryClient(); // 1 singleton / tab
return browserQueryClient;
}
Giờ generateMetadata gọi getQueryClient() và page cũng gọi getQueryClient() trong cùng request sẽ nhận cùng một cache → prefetch một lần, dehydrate một lần ở ngoài cùng truyền hết xuống.
cache()ở đây làReact.cache— request-scoped trên server, không liên quan tới HTTP cache hayunstable_cachecủa Next. Nó chỉ memo trong phạm vi một lần render server.
13. Pages Router (cũ): getServerSideProps + dehydrate
Nếu còn ở Pages Router, mô hình giống hệt về bản chất — chỉ khác chỗ chạy prefetch: trong getServerSideProps/getStaticProps, rồi truyền dehydratedState qua props:
// pages/customers.tsx
import { QueryClient, dehydrate, HydrationBoundary, useQuery } from '@tanstack/react-query';
import { customersQuery } from '@/features/customers/queries';
export async function getServerSideProps() {
const queryClient = new QueryClient(); // mới mỗi request — KHÔNG singleton ở server
await queryClient.prefetchQuery(customersQuery());
return { props: { dehydratedState: dehydrate(queryClient) } };
}
export default function CustomersPage({ dehydratedState }: { dehydratedState: unknown }) {
return (
<HydrationBoundary state={dehydratedState}>
<CustomerList />
</HydrationBoundary>
);
}
function CustomerList() {
const { data } = useQuery(customersQuery());
return <ul>{data?.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}
QueryClientProvider đặt ở _app.tsx (singleton browser, như App Router). Khác biệt duy nhất: App Router prefetch trong Server Component; Pages Router prefetch trong getServerSideProps. Cùng dehydrate → HydrationBoundary → useQuery.
14. Gotchas thường gặp
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
| Hydrate xong client refetch ngay | staleTime: 0 | Đặt staleTime > 0 ở makeQueryClient (mục 5) |
"Text content does not match" | Data server ≠ client (vd Date.now(), Math.random() trong render) | Render tất định; tránh giá trị đổi giữa hai lượt |
| Data của user A hiện cho user B | Dùng chung 1 client toàn cục/module-scope trên server | getQueryClient() tạo mới mỗi request; RSC bọc cache() (mục 12) |
| Hydrate không khớp, fetch lại | queryKey server ≠ client | Dùng chung queryOptions (mục 9) |
| Lỗi prefetch làm sập trang | Dùng fetchQuery (throw) cho data không bắt buộc | Dùng prefetchQuery (nuốt lỗi) |
data thành null/biến dạng sau hydrate | Trả Date/Map/Set/class — không serialize được | Trả POJO; hoặc cấu hình serialize/deserialize (vd superjson) |
Query pending không stream xuống | Thiếu 'pending' trong shouldDehydrateQuery | Thêm như mục 4 khi dùng streamed hydration |
let client module-scope server giữ data cũ | Biến module sống xuyên request trên server | Không bao giờ cache QueryClient ở module-scope server (chỉ browser) |
Cấu hình superjson cho data phức tạp
Nếu bắt buộc truyền Date/Map/Set, cắm bộ serialize tuỳ biến vào cả hai đầu:
import superjson from 'superjson';
// makeQueryClient()
new QueryClient({
defaultOptions: {
dehydrate: { serializeData: superjson.serialize },
hydrate: { deserializeData: superjson.deserialize },
},
});
Cấu hình đối xứng (cùng superjson hai phía) là điều kiện sống còn — lệch một bên là vỡ hydrate.
15. Recipe: trang chi tiết với prefetch song song + <title> từ data
Gộp các mảnh: prefetch song song, đọc một giá trị ngay trên server cho metadata, rồi hydrate phần còn lại xuống client. Bản này dùng helper bọc cache() (mục 12) nên generateMetadata và page chia sẻ một cache trong cùng request.
// app/products/[id]/page.tsx (Server Component)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import type { Metadata } from 'next';
import { getQueryClient } from '../../get-query-client';
import { productQuery, reviewsQuery } from '@/features/products/queries';
import { ProductView } from '@/features/products/ProductView';
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> },
): Promise<Metadata> {
const { id } = await params;
const queryClient = getQueryClient();
// ensureQueryData: trả về GIÁ TRỊ để set title (khác prefetchQuery trả void)
const product = await queryClient.ensureQueryData(productQuery(id));
return { title: product.name };
}
export default async function ProductPage(
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const queryClient = getQueryClient();
// Song song: chi tiết + reviews
await Promise.all([
queryClient.prefetchQuery(productQuery(id)),
queryClient.prefetchQuery(reviewsQuery(id)),
]);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductView id={id} />
</HydrationBoundary>
);
}
ProductView (Client Component) gọi useQuery(productQuery(id)) và useQuery(reviewsQuery(id)) — cả hai hydrate trúng, render tức thì, không spinner, SEO đầy đủ.
Vì
generateMetadatavàProductPagecùng dùnggetQueryClient()(đã bọccache()ở mục 12),ensureQueryDataởgenerateMetadatavàprefetchQueryởProductPagechia sẻ một cache trong cùng request →productkhông fetch hai lần.
16. Bài tập
1. Vì sao SSR gần như luôn cần staleTime > 0?
Lời giải
Khi hydrate, mọi query nhận dataUpdatedAt từ lúc server fetch. Nếu staleTime là 0, data lập tức bị coi là stale và client refetch ngay khi mount (theo refetchOnMount), làm phí toàn bộ công prefetch của server và gây nháy. staleTime > 0 giữ data “tươi” đủ lâu để client không fetch lại ngay.
2. Vì sao trên server phải tạo QueryClient mới mỗi request, còn browser thì singleton?
Lời giải
Trên server, một client dùng chung sẽ trộn cache của nhiều user/request → rò rỉ dữ liệu và race. Mỗi request phải có cache riêng. Trên browser chỉ có một user và một phiên, nên singleton giúp giữ cache xuyên suốt các lần re-render/Suspense; tạo mới sẽ xoá sạch cache.
3. Lợi ích và rủi ro của streamed hydration so với prefetch + HydrationBoundary?
Lời giải
Streamed hydration gửi shell ngay rồi stream data khi sẵn sàng (TTFB tốt, dùng useSuspenseQuery trực tiếp trên server, ít boilerplate). Rủi ro: gói còn “experimental”, hành vi có thể đổi, và khó kiểm soát thứ tự/ưu tiên data hơn cách prefetch tường minh. Cách prefetch + HydrationBoundary ổn định và dễ debug hơn cho production.
4. Bạn dùng initialData: serverData cho một useQuery và thấy nó không refetch sau hydrate dù staleTime là 0. Vì sao? (Gợi ý: dataUpdatedAt.)
Lời giải
initialData mặc định gán dataUpdatedAt = thời điểm mount client, nên data được xem như vừa fetch xong → chưa stale → không refetch. Đây vừa là tiện lợi vừa là bẫy: nếu serverData thực ra đã cũ, bạn vẫn hiển thị nó như “mới”. Muốn React Query biết tuổi thật, truyền initialDataUpdatedAt (mốc server fetch). Hydration thì luôn giữ dataUpdatedAt thật nên không gặp vấn đề này.
5. Một query trả { createdAt: new Date() }. Sau hydrate, data.createdAt trở thành string chứ không phải Date, và .getFullYear() ném lỗi. Chuyện gì xảy ra và sửa thế nào?
Lời giải
dehydrate gọi JSON.stringify, biến Date thành chuỗi ISO; hydrate (qua JSON.parse) không biết phục hồi lại thành Date. Hai cách sửa: (1) trả số/string ISO rồi tự new Date(...) ở UI; (2) cắm superjson (hoặc bộ serialize tương tự) vào dehydrate.serializeData + hydrate.deserializeData đối xứng hai phía để Date/Map/Set được khôi phục đúng kiểu.
Nâng cao: Dựng một route Next App Router prefetch danh sách trên server, hydrate xuống một Client Component dùng useQuery. Mở Network tab, tắt JS — xác nhận HTML đã chứa data (SEO). Bật lại JS — xác nhận client không refetch ngay (nhờ staleTime).
Tóm tắt
- SSR React Query = server prefetch →
dehydrate→ “wire” → client<HydrationBoundary>→ hydrate; sau đó client tiếp quản như app thường. dehydratechỉ xuất snapshot JSON các query thoảshouldDehydrateQuery, giữ nguyêndataUpdatedAt;hydratenhồi snapshot đó vào cache client.getQueryClient()phải tạo mới trên server, singleton trên browser (isServer); trong RSC bọc nhánh server bằngReact.cacheđể một client cho mỗi request (không rò data giữa user).- Đặt
staleTime > 0để tránh double fetch: data vừa hydrate không bị client refetch ngay sau mount. - Dùng chung một
queryOptionscho cả prefetch (server) vàuseQuery/useInfiniteQuery(client) đểqueryKeykhớp tuyệt đối. initialData= từng query lẻ qua props,dataUpdatedAt= lúc mount; Hydration = cả cache, giữ tuổi data thật — chọn Hydration làm mặc định.- Chỉ truyền data serialize được; cần
Date/Map/Setthì cắmsuperjsonđối xứng hai phía. @tanstack/react-query-next-experimentalcho streaming vớiuseSuspenseQuerytrực tiếp trên server; đổi lại còn experimental.- Pages Router cùng mô hình: prefetch trong
getServerSideProps→dehydratequa props →HydrationBoundary.
Phần tiếp theo
Phần 11 — Prefetching & tích hợp Router nâng cao: vượt khỏi prefetch-on-hover cơ bản — prefetch theo ý định (hover/focus/viewport), ensureQueryData vs prefetchQuery, prefetchInfiniteQuery cho danh sách vô hạn, tích hợp route loader (TanStack Router + React Router) và cách triệt tiêu request waterfall.