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

SSR with React Query and Loader Data

Hello! Let's dive into our next topic.

In our last lesson, we concluded our module on the foundations of Tanstack Start by comparing the Node.js and Bun runtimes. We established a clear framework for choosing between Node's stability and Bun's performance, a key architectural decision for any SSR application.

Today, we begin a new module: SSR Data and State Management. This is where we connect the server and client worlds to create a seamless user experience. Our first goal is to tackle this learning outcome:

Configure React Query with initialData from route loaders for server-side rendering.

We'll explore the most direct method for taking data fetched on the server and "seeding" it into the React Query cache on the client. This prevents the common problem of the client immediately re-fetching data that the server has already provided. We'll learn how to implement this pattern and, just as importantly, analyze its specific trade-offs to understand when it's appropriate to use.

1. The SSR Data-Fetching Challenge

In the previous module, we saw how to use Tanstack Router's loader function to fetch data on the server before a component renders. This is excellent for performance and SEO, as the initial HTML sent to the browser contains the necessary data.

However, a problem arises once the JavaScript loads and React "hydrates" on the client. Your client-side code, including React Query, has no knowledge of the data that was fetched and used on the server.

Consider this flow:

  1. Server: The route loader fetches a list of projects. The server renders the React component with these projects, generating HTML.
  2. Client: The browser receives and displays the HTML. The user sees the projects instantly.
  3. Client (Hydration): The JavaScript bundle loads. React Query initializes. A component calls useQuery({ queryKey: ['projects'], queryFn: fetchProjects }).
  4. The Problem: React Query's cache for ['projects'] is empty. It immediately executes fetchProjects to get the data, even though that data is already visible on the screen.

This results in a wasted network request and can cause a brief flicker if the newly fetched data differs slightly from the server-rendered data. Our goal is to bridge this gap and inform the client-side React Query cache about the server-fetched data.

2. The initialData Solution

React Query provides a straightforward option on its useQuery hook to handle this exact scenario: initialData. This option allows you to provide data to populate the query's cache on its initial render, if the cache is currently empty.

Let's examine the official documentation to see how this works in practice and to understand its limitations.

Server Rendering & Hydration | TanStack Query React Docs

The official TanStack Query documentation provides the clearest explanation of the initialData pattern for SSR. It's presented as a 'quick start' method with important caveats.

Find the section titled 'Get started fast with initialData' and read the initialData guide. The Remix code example is very analogous to how you would implement this in Tanstack Router. Pay close attention to the implementation and the list of trade-offs at the end of the section.

3. Deconstructing the Pattern

As the documentation shows, the implementation involves two distinct parts: the server-side loader and the client-side component. Let's break down the code for a Tanstack Start/Router application.

Step 1: The Route Loader

The loader's job is simple: fetch the data and return it as a plain object. This part of the process is identical to what we've seen before.

// src/routes/projects.tsx
import { createFileRoute } from '@tanstack/react-router';
import { fetchProjects } from '../utils/api'; // Assume this function fetches projects

export const Route = createFileRoute('/projects')({
  // The loader runs on the server for the initial page load
  loader: async () => {
    const projects = await fetchProjects();
    // The returned object is passed to the component
    return { projects };
  },
  component: ProjectsComponent,
});

Step 2: The Component

The component receives the loader's data and passes it to useQuery.

// src/routes/projects.tsx (continued)
import { useQuery } from '@tanstack/react-query';

function ProjectsComponent() {
  // 1. Get the data returned from the loader
  const { projects: initialProjects } = Route.useLoaderData();

  // 2. Use the data to seed React Query's cache
  const { data: projects } = useQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    // This is the key part:
    initialData: initialProjects,
  });

  return (
    <div>
      <h1>Projects</h1>
      <ul>
        {projects.map((project) => (
          <li key={project.id}>{project.name}</li>
        ))}
      </ul>
    </div>
  );
}

What happens here?

  • On the server, loader runs, fetchProjects is called, and the component is rendered with the data.
  • On the client, Route.useLoaderData() provides the same data that was fetched on the server.
  • useQuery is called. It sees that its cache for ['projects'] is empty, but initialData is present.
  • It immediately populates the cache with initialProjects and returns it as data.
  • Crucially, it does not call the queryFn on this initial client-side render, successfully avoiding the redundant network request.

4. Analyzing the Trade-offs

The initialData pattern is simple and effective for the initial page load. However, as the documentation you read points out, it comes with significant trade-offs. For your work as a senior developer, understanding these limitations is essential for making sound architectural decisions.

Let's review them in the context of a real-world application:

  1. Prop Drilling: If the component that needs the data (<ProjectList />) is nested deep inside ProjectsComponent, you would have to pass initialProjects down through multiple layers of props. This can become messy and hard to maintain.

  2. Brittle with Multiple useQuery Calls: Imagine two different components on the page both call useQuery({ queryKey: ['projects'], ... }). You must ensure initialData is passed to both, or that the component with initialData renders first. This creates a fragile dependency between components.

  3. Inaccurate Freshness Information: React Query doesn't know when the data was fetched on the server. It sets the dataUpdatedAt timestamp to the moment the query is initialized on the client. If you have a staleTime of 60 seconds, the data will be considered fresh for 60 seconds from the time the client loaded, not from when the server fetched it. This could lead to data being considered "fresh" when it's actually older than you think.

  4. Stale Data on Subsequent Navigations (The Biggest Drawback):

    • A user loads the /projects page. initialData works perfectly.
    • The user navigates to /about, then clicks a link to go back to /projects.
    • Tanstack Router is smart and will run the loader again on the client to get the freshest data.
    • However, the useQuery hook sees that the cache for ['projects'] already has data (from the first visit). The initialData option is ignored if data already exists in the cache.
    • The result: The loader fetches fresh data, but React Query ignores it and continues to show the old, stale data from the cache.

This final point makes the initialData pattern unsuitable for data that needs to be kept fresh across client-side navigations.

Conclusion

In this lesson, we learned how to use the initialData option in React Query to bridge the gap between server-side data fetching and client-side state management. It's a straightforward pattern that effectively prevents a redundant fetch on the initial page load.

Key Takeaways:

  • The Pattern: Fetch data in a Tanstack Router loader, access it in your component with useLoaderData, and pass it to useQuery's initialData option.
  • The Purpose: To seed the React Query cache on the client with data that was already fetched on the server, preventing an unnecessary initial API call.
  • The Limitation: initialData is only used when the cache for a given query key is empty. This makes it problematic for keeping data fresh during client-side navigation, leading to a poor user experience with stale data.

Because of these limitations, initialData is best reserved for data that is truly static and will not change throughout the user's session. For most dynamic application data, we need a more robust solution.

This brings us perfectly to our next topic. In the next lesson, we will implement the full React Query hydration flow. This involves prefetching on the server, dehydrating the entire query cache into a serializable format, sending it to the client, and rehydrating the client's cache. This advanced pattern solves all the trade-offs of initialData and is the recommended approach for building complex SSR applications with Tanstack Start and React Query.

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

Sign up