Recommended resources
The freely available resources this lesson will direct you to.
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:
- Server: The route loader fetches a list of projects. The server renders the React component with these projects, generating HTML.
- Client: The browser receives and displays the HTML. The user sees the projects instantly.
- Client (Hydration): The JavaScript bundle loads. React Query initializes. A component calls
useQuery({ queryKey: ['projects'], queryFn: fetchProjects }). - The Problem: React Query's cache for
['projects']is empty. It immediately executesfetchProjectsto 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,
loaderruns,fetchProjectsis called, and the component is rendered with the data. - On the client,
Route.useLoaderData()provides the same data that was fetched on the server. useQueryis called. It sees that its cache for['projects']is empty, butinitialDatais present.- It immediately populates the cache with
initialProjectsand returns it asdata. - Crucially, it does not call the
queryFnon 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:
-
Prop Drilling: If the component that needs the data (
<ProjectList />) is nested deep insideProjectsComponent, you would have to passinitialProjectsdown through multiple layers of props. This can become messy and hard to maintain. -
Brittle with Multiple
useQueryCalls: Imagine two different components on the page both calluseQuery({ queryKey: ['projects'], ... }). You must ensureinitialDatais passed to both, or that the component withinitialDatarenders first. This creates a fragile dependency between components. -
Inaccurate Freshness Information: React Query doesn't know when the data was fetched on the server. It sets the
dataUpdatedAttimestamp to the moment the query is initialized on the client. If you have astaleTimeof 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. -
Stale Data on Subsequent Navigations (The Biggest Drawback):
- A user loads the
/projectspage.initialDataworks perfectly. - The user navigates to
/about, then clicks a link to go back to/projects. - Tanstack Router is smart and will run the
loaderagain on the client to get the freshest data. - However, the
useQueryhook sees that the cache for['projects']already has data (from the first visit). TheinitialDataoption 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.
- A user loads the
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 withuseLoaderData, and pass it touseQuery'sinitialDataoption. - 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:
initialDatais 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.