Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

React Query Hydration: Server to Client

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:

  1. Prefetching (Server): In the route's loader function, we'll use a temporary, request-specific QueryClient to prefetch data. This populates a server-side cache with the query data and its metadata (like the fetch timestamp).
  2. Dehydration (Server): After prefetching, we'll use React Query's dehydrate function 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.
  3. 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-side QueryClient before 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.all to 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 to prefetchQuery for 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 code snippet from the TanStack Query documentation shows how to configure a `QueryClient` with custom `serialize` and `deserialize` functions, enabling the transfer of complex data types during SSR hydration.

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 prefetchQuery in a request-specific QueryClient on the server, dehydrate to serialize the cache, and <HydrationBoundary> to rehydrate the main QueryClient on 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 QueryClient instance for each server request (e.g., inside the loader) to prevent sharing data between users. The client-side QueryClient should 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.

Can't find a good explanation? Sign up and we'll make it for you

Sign up