Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our course.
In our last session, we explored the initialData approach for seeding React Query's cache in an SSR application. We established that while it's simple for an initial page load, its significant limitations—especially causing stale data on client-side navigations—make it unsuitable for most dynamic data.
Today, we'll implement the solution to those problems. This lesson addresses the following learning outcome:
Implement the full React Query hydration flow: prefetching on the server, dehydrating the cache, and rehydrating on the client.
This is the canonical, robust pattern for using React Query in an SSR context. It ensures data fetched on the server is seamlessly and efficiently transferred to the client, preserving its freshness state and avoiding unnecessary API calls, all while keeping your component architecture clean and decoupled.
1. The Three Pillars of React Query Hydration
The initialData method was like handing a single piece of paper from the server to the client. The full hydration flow is more like the server compiling a complete dossier, shipping it to the client, and the client integrating it into its own filing system. This process stands on three pillars:
- Prefetching (Server): In the route's
loaderfunction, we'll use a temporary, request-specificQueryClientto prefetch data. This populates a server-side cache with the query data and its metadata (like the fetch timestamp). - Dehydration (Server): After prefetching, we'll use React Query's
dehydratefunction to serialize the entire state of this server-side cache. This creates a plain, serializable object that can be safely sent to the client as part of the loader's return value. - Rehydration (Client): On the client, we'll use the
<HydrationBoundary>component. It takes the dehydrated state from the server and uses it to "rehydrate" the main client-sideQueryClientbefore your components render.
This process ensures that when a component on the client calls useQuery, the data is already in the cache, appearing instantly and synchronously, complete with the correct dataUpdatedAt timestamp from when the server fetched it.
Let's start by looking at the official documentation, which lays out this entire process.
Server Rendering & Hydration | TanStack Query React Docs
The official TanStack Query documentation provides the definitive guide to server rendering and hydration. It explains the concepts and provides complete examples that we can adapt for Tanstack Router.
Please read the following sections: Start with the introduction, 'Server Rendering & React Query', to solidify the concepts of prefetch, dehydrate, and hydrate. Then, read 'Using the Hydration APIs' for a high-level overview of the steps involved. Finally, review the 'Full Remix example'. The loader and component structure in Remix is very similar to Tanstack Router, making it an excellent reference for our purposes.
2. Implementing the Hydration Flow
Now, let's translate the concepts from the documentation into a concrete implementation for a Tanstack Start application.
Step 1: Ensure a Per-Request QueryClient
A critical aspect of SSR is data isolation. You must never share a QueryClient instance between different user requests on the server, as this would leak data. The documentation you just read emphasizes creating the client-side QueryClient within the component tree (often using useState) to ensure it's a singleton for the browser session.
In a Tanstack Start app, you'd typically configure this in your root.tsx file.
// src/root.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
import React from 'react';
// Define the context for the router, including the query client
interface MyRouterContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
component: RootComponent,
});
function RootComponent() {
// Use React.useState to create the client-side queryClient.
// This ensures it's created only once per client-side session.
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// A non-zero staleTime is recommended with SSR
// to prevent immediate refetches on page load.
staleTime: 60 * 1000, // 1 minute
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{/* The rest of your app */}
<Outlet />
</QueryClientProvider>
);
}
With this setup, every component in your app has access to the same client-side QueryClient instance via the provider.
Step 2: Prefetch and Dehydrate in the Loader
Now for the server-side part. In your route file, the loader function becomes the orchestration point for prefetching and dehydration.
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router';
import {
QueryClient,
dehydrate,
HydrationBoundary,
useQuery,
} from '@tanstack/react-query';
import { fetchPosts } from '../utils/api'; // Your data fetching function
export const Route = createFileRoute('/posts')({
// The loader runs on the server for the initial request
loader: async () => {
// 1. Create a NEW QueryClient for this specific server-side request.
const queryClient = new QueryClient();
// 2. Prefetch the query. This fetches the data and populates the server-side cache.
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
// 3. Dehydrate the cache and return it.
// The object is automatically serialized and sent to the client.
return {
dehydratedState: dehydrate(queryClient),
};
},
component: PostsComponent,
});
// ... component implementation in the next step
Notice we create a new QueryClient() right inside the loader. This is the temporary, single-use client for this server request, ensuring data isolation.
Step 3: Rehydrate and Render on the Client
The final step is to use the dehydrated state on the client. The <HydrationBoundary> component from React Query is designed for exactly this.
// src/routes/posts.tsx (continued)
// This component can be nested anywhere and doesn't need any props.
function PostList() {
// 3. useQuery works as usual. React Query finds the data in the
// cache (put there by HydrationBoundary) and returns it synchronously.
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
return (
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
function PostsComponent() {
// 1. Access the dehydrated state from the loader's data.
const { dehydratedState } = Route.useLoaderData();
// 2. Wrap the part of your component tree that uses the prefetched
// data with HydrationBoundary.
return (
<HydrationBoundary state={dehydratedState}>
<h1>Posts</h1>
<PostList />
</HydrationBoundary>
);
}
This pattern is powerful because PostList has no direct dependency on the loader or PostsComponent. It is completely decoupled. As long as it's rendered within a <HydrationBoundary> that has been provided the correct state, it will receive its data from the cache instantly. This elegantly solves the prop-drilling issue we identified with initialData.
To see a visual walkthrough of this pattern, the following video provides an excellent demonstration. While it uses Next.js, the React Query concepts are identical.
Next 14 + React Query COMBO with Server Actions and RSC
This video from the developedbyed channel contrasts the initialData approach with the superior HydrationBoundary method. Watching this will help solidify your understanding of why and how the full hydration flow works.
Please watch the segment which covers 'Method 2: Hydration Boundary'. The presenter clearly explains how to use dehydrate and HydrationBoundary to avoid prop drilling and improve the overall architecture.
3. Advanced Considerations for Production
As a senior developer, it's important to be aware of a few more details that are crucial for production applications.
Parallel and Dependent Prefetching
Your loader isn't limited to a single query.
- Parallel: To fetch multiple independent queries, use
Promise.allto run them concurrently, minimizing server-side latency.await Promise.all([ queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts }), queryClient.prefetchQuery({ queryKey: ['user'], queryFn: getUser }), ]); - Dependent: For queries that depend on each other, you must fetch them sequentially. Use
fetchQuery(which returns the data) for the parent query, then use its result toprefetchQueryfor the child.const user = await queryClient.fetchQuery({ queryKey: ['user'], queryFn: getUser }); if (user) { await queryClient.prefetchQuery({ queryKey: ['projects', user.id], queryFn: () => getProjectsForUser(user.id), }); }
Data Serialization
The dehydrate function produces a JSON-serializable object. This means complex data types like Date, Map, Set, or BigInt in your query responses will be lost during the transfer from server to client.
If you need to preserve these types, you must use a serialization library like superjson. This involves configuring both the dehydration and hydration processes to use custom serializers and deserializers.
This is an advanced pattern, but knowing it exists is key to avoiding subtle bugs when your API returns non-standard JSON data types.
Conclusion
In this lesson, we've implemented the complete React Query hydration flow, the standard for building robust SSR applications. You now have the tools to efficiently manage server-fetched data on the client.
Key Takeaways:
- The Full Flow: The process involves
prefetchQueryin a request-specificQueryClienton the server,dehydrateto serialize the cache, and<HydrationBoundary>to rehydrate the mainQueryClienton the client. - Solves
initialData's Flaws: This pattern eliminates prop drilling, preserves data freshness (dataUpdatedAt), and works correctly on subsequent client-side navigations. - Data Isolation is Key: Always use a new
QueryClientinstance for each server request (e.g., inside theloader) to prevent sharing data between users. The client-sideQueryClientshould be a singleton for the app's lifecycle. - Performance: You can optimize server-side fetching by running independent queries in parallel with
Promise.all.
We've now established a solid foundation for fetching data from our own backend. However, many modern applications rely on standardized API specifications like OpenAPI.
Next Up: In our next lesson, we will learn how to integrate OpenAPI-generated React Query hooks within server-side route loaders. This will allow us to leverage type-safe, auto-generated hooks for data fetching, further improving the developer experience and robustness of our SSR application.