159 lines
3.8 KiB
Markdown
159 lines
3.8 KiB
Markdown
# ssr-dehydration: Use Dehydrate/Hydrate Pattern for SSR
|
|
|
|
## Priority: MEDIUM
|
|
|
|
## Explanation
|
|
|
|
For server-side rendering, prefetch queries on the server, dehydrate the cache to a serializable format, send it to the client, and hydrate on the client. This prevents content flash and duplicate requests.
|
|
|
|
## Bad Example
|
|
|
|
```tsx
|
|
// No SSR data passing - client refetches everything
|
|
// server-side
|
|
export async function getServerSideProps() {
|
|
const data = await fetchPosts()
|
|
return { props: { posts: data } } // Bypasses React Query cache
|
|
}
|
|
|
|
// client-side
|
|
function PostsPage({ posts }: { posts: Post[] }) {
|
|
// This doesn't benefit from the server fetch
|
|
const { data } = useQuery({
|
|
queryKey: ['posts'],
|
|
queryFn: fetchPosts,
|
|
// Will refetch on client, causing flash
|
|
})
|
|
|
|
return <PostList posts={data ?? posts} /> // Awkward fallback pattern
|
|
}
|
|
```
|
|
|
|
## Good Example: Next.js App Router
|
|
|
|
```tsx
|
|
// app/posts/page.tsx
|
|
import {
|
|
dehydrate,
|
|
HydrationBoundary,
|
|
QueryClient,
|
|
} from '@tanstack/react-query'
|
|
import { postQueries } from '@/lib/queries'
|
|
|
|
export default async function PostsPage() {
|
|
const queryClient = new QueryClient()
|
|
|
|
await queryClient.prefetchQuery(postQueries.list())
|
|
|
|
return (
|
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
<PostList />
|
|
</HydrationBoundary>
|
|
)
|
|
}
|
|
|
|
// components/PostList.tsx
|
|
'use client'
|
|
|
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
|
import { postQueries } from '@/lib/queries'
|
|
|
|
export function PostList() {
|
|
const { data: posts } = useSuspenseQuery(postQueries.list())
|
|
|
|
return (
|
|
<ul>
|
|
{posts.map(post => (
|
|
<li key={post.id}>{post.title}</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Good Example: TanStack Start/Router
|
|
|
|
```tsx
|
|
// routes/posts.tsx
|
|
import { createFileRoute } from '@tanstack/react-router'
|
|
import { postQueries } from '@/lib/queries'
|
|
|
|
export const Route = createFileRoute('/posts')({
|
|
loader: async ({ context: { queryClient } }) => {
|
|
// Prefetch in route loader
|
|
await queryClient.ensureQueryData(postQueries.list())
|
|
},
|
|
component: PostsPage,
|
|
})
|
|
|
|
function PostsPage() {
|
|
const { data: posts } = useSuspenseQuery(postQueries.list())
|
|
return <PostList posts={posts} />
|
|
}
|
|
```
|
|
|
|
## Good Example: Manual SSR Setup
|
|
|
|
```tsx
|
|
// server.tsx
|
|
import { dehydrate, QueryClient } from '@tanstack/react-query'
|
|
import { renderToString } from 'react-dom/server'
|
|
|
|
export async function render(url: string) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 60 * 1000, // Prevent immediate client refetch
|
|
},
|
|
},
|
|
})
|
|
|
|
// Prefetch required data
|
|
await queryClient.prefetchQuery({
|
|
queryKey: ['posts'],
|
|
queryFn: fetchPosts,
|
|
})
|
|
|
|
const dehydratedState = dehydrate(queryClient)
|
|
|
|
const html = renderToString(
|
|
<QueryClientProvider client={queryClient}>
|
|
<App />
|
|
</QueryClientProvider>
|
|
)
|
|
|
|
// Serialize safely - JSON.stringify is XSS vulnerable
|
|
const serializedState = serialize(dehydratedState)
|
|
|
|
return `
|
|
<html>
|
|
<body>
|
|
<div id="app">${html}</div>
|
|
<script>window.__DEHYDRATED_STATE__ = ${serializedState}</script>
|
|
</body>
|
|
</html>
|
|
`
|
|
}
|
|
|
|
// client.tsx
|
|
import { hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
|
|
const queryClient = new QueryClient()
|
|
hydrate(queryClient, window.__DEHYDRATED_STATE__)
|
|
|
|
hydrateRoot(
|
|
document.getElementById('app'),
|
|
<QueryClientProvider client={queryClient}>
|
|
<App />
|
|
</QueryClientProvider>
|
|
)
|
|
```
|
|
|
|
## Context
|
|
|
|
- Create new QueryClient per request to prevent data sharing between users
|
|
- Set `staleTime > 0` on server to prevent immediate client refetch
|
|
- Use a safe serializer (not JSON.stringify) to prevent XSS
|
|
- Failed queries aren't dehydrated by default; use `shouldDehydrateQuery` to override
|
|
- `HydrationBoundary` can be nested for route-level prefetching
|