jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 QueryClient mớ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 queriesnhồ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, undefined lồng trong object, class instance… đều rụng hoặc vỡ khi JSON.stringify. Trả về POJO thuần (hoặc cấu hình serialize/deserialize tuỳ biến — xem Gotchas).


3. Bảng tra cứu: API SSR cốt lõi

APIChạy ở đâuTrả vềDùng để
prefetchQuery(opts)ServerPromise<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)ServerPromise<void>Như trên, cho useInfiniteQuery
fetchQuery(opts)Server/ClientPromise<TData> (throw lỗi)Cần giá trị trả về, hoặc muốn lỗi nổi lên
ensureQueryData(opts)Server/ClientPromise<TData>Như fetchQuery nhưng trả cache nếu đã fresh
dehydrate(client, opts?)ServerDehydratedState (JSON)Đóng băng cache thành JSON để gửi xuống
hydrate(client, state)ClientvoidNạp DehydratedState vào cache client
<HydrationBoundary state>ClientJSXGọi hydrate cho cây con; an toàn lồng nhau

prefetchQuery = fetchQuery nhưng trả voidkhông throw. Trên server, prefetch song song nhiều query rồi dehydrate một lần là pattern chuẩn. Khi cần đọc giá trị ngay trên server (vd để set <title>), dùng fetchQuery/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_000 không phải tuỳ chọn — nó gần như bắt buộc cho SSR. Nếu để mặc định 0, 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 > 0isStale = 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
staleTimeSau hydrateHệ quả
0 (mặc định)Refetch ngay khi mountPhí 1 request, có thể nháy UI
> 0 (vd 60_000)Dùng data hydrate, không fetchĐúng tinh thần SSR
InfinityKhông bao giờ tự refetchTố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ùng 60_000 là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)

prefetchQuery khô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ùng fetchQuery.

// 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 initialPageParamgetNextPageParam để 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: 3 trê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íinitialDataHydration (dehydrate + HydrationBoundary)
Phạm viTừ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 hydrateCoi như mới → dễ không refetchTheo staleTime so với tuổi thật
Truyền dataQua props (prop drilling)Tự động qua <script> cache
Hợp với1-2 query đơn giản, props sẵn cóApp nhiều query, mô hình chuẩn SSR
Rủi roinitialData 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). initialData chỉ tiện cho component lẻ đã có sẵn data qua props. Nếu dùng initialData, cân nhắc truyền kèm initialDataUpdatedAt để staleTime tí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 hay unstable_cache củ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 dehydrateHydrationBoundaryuseQuery.


14. Gotchas thường gặp

Triệu chứngNguyên nhânCách sửa
Hydrate xong client refetch ngaystaleTime: 0Đặt staleTime > 0makeQueryClient (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 BDùng chung 1 client toàn cục/module-scope trên servergetQueryClient() tạo mới mỗi request; RSC bọc cache() (mục 12)
Hydrate không khớp, fetch lạiqueryKey server ≠ clientDùng chung queryOptions (mục 9)
Lỗi prefetch làm sập trangDùng fetchQuery (throw) cho data không bắt buộcDùng prefetchQuery (nuốt lỗi)
data thành null/biến dạng sau hydrateTrả Date/Map/Set/class — không serialize đượcTrả POJO; hoặc cấu hình serialize/deserialize (vd superjson)
Query pending không stream xuốngThiếu 'pending' trong shouldDehydrateQueryThê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 serverKhô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))useQuery(reviewsQuery(id)) — cả hai hydrate trúng, render tức thì, không spinner, SEO đầy đủ.

generateMetadataProductPage cùng dùng getQueryClient() (đã bọc cache() ở mục 12), ensureQueryDatagenerateMetadataprefetchQueryProductPage chia sẻ một cache trong cùng request → product khô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ù staleTime0. 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.
  • dehydrate chỉ xuất snapshot JSON các query thoả shouldDehydrateQuery, giữ nguyên dataUpdatedAt; hydrate nhồ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ằng React.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 queryOptions cho cả prefetch (server) và useQuery/useInfiniteQuery (client) để queryKey khớ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/Set thì cắm superjson đối xứng hai phía.
  • @tanstack/react-query-next-experimental cho streaming với useSuspenseQuery trực tiếp trên server; đổi lại còn experimental.
  • Pages Router cùng mô hình: prefetch trong getServerSidePropsdehydrate qua 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.