Hello! Welcome back to our series on migrating a Single-Page Application to TanStack Start.
In our last lesson, we focused on identifying and isolating client-only code using tools like <ClientOnly> and createIsomorphicFn. This was a crucial first step in making our application compatible with a Server-Side Rendering (SSR) environment.
Today, we'll tackle one of the most impactful parts of an SSR migration. Our goal is to refactor data fetching from client-side useEffect hooks to server-side route loaders. By moving data fetching from the browser to the server for the initial page load, we can significantly improve performance, enhance SEO, and eliminate those initial loading spinners that are characteristic of SPAs.
This lesson will walk you through the "why" and "how" of this refactoring process, transforming the user experience of your application's initial load.
1. The Classic SPA Pattern: Client-Side Fetching
As an experienced front-end developer, you're undoubtedly familiar with fetching data inside a component using the useEffect hook. This pattern is the bedrock of most SPAs.
A typical component might look like this:
// A typical client-side data fetching component
function ProductDetails({ productId }) {
const [product, setProduct] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetch(`/api/products/${productId}`)
.then(res => {
if (!res.ok) throw new Error('Product not found');
return res.json();
})
.then(data => setProduct(data))
.catch(err => setError(err))
.finally(() => setIsLoading(false));
}, [productId]);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!product) return null;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
The lifecycle is clear:
- The browser receives a minimal HTML shell.
- React hydrates and mounts the
ProductDetailscomponent. - The
useEffecthook triggers thefetchrequest. - The user sees a "Loading..." state.
- The data arrives, the state updates, and the component re-renders with the product information.
This creates a "request waterfall" on the client. While frameworks like TanStack Query can optimize this with useQuery, the fundamental client-side fetch for the initial render remains. The video below briefly demonstrates this initial client-side fetching pattern, which we are about to refactor.
SSR, Preloading, Caching and more with TanStack Start + Query
This clip from the video "SSR, Preloading, Caching and more with TanStack Start + Query" by Dev Leonardo shows a standard client-side data fetching setup. Notice the network request and loading state on the initial render.
Watch from 00:27 to 01:12. This sets the stage by showing the 'before' state that we are going to improve upon.
2. The SSR Solution: TanStack Router Loaders
TanStack Router provides a powerful abstraction to solve this problem: route loaders. A loader is a function associated with a route that runs before the route component renders.
- On initial page load (SSR): The loader runs on the server. It fetches the data, and the result is serialized and embedded directly into the HTML sent to the browser.
- On client-side navigation: The loader runs in the browser, fetching the data for the next page before rendering it.
This means your component receives its data immediately upon rendering, eliminating the client-side fetch and loading state for the initial paint.
To understand the fundamentals, let's turn to the official documentation.
Data Loading | TanStack Router React Docs
The "Data Loading" guide from the TanStack Router documentation is the definitive source on this topic. It explains the concept of loaders and how to use them.
Please read the sections "Route loader s" and "Consuming data from loader s". This will introduce you to the core API: defining a loader function in your route and accessing its data with the useLoaderData hook.
3. A Practical Refactor: From useEffect to loader
Let's apply this knowledge by refactoring our ProductDetails component. We'll follow the excellent tutorial from the TanStack Start docs, which demonstrates fetching data from an external API.
Calling an external API using TanStack Start
This tutorial, "Calling an external API using TanStack Start", provides a complete, practical example of what we're trying to achieve. It builds a movie-fetching app, demonstrating the full data flow.
Focus on 'Step 3: Creating the Route with API Fetch Function', 'Step 5: Creating the MoviesPage Component', and the 'Understanding How It All Works Together' summary. These sections show exactly how to define a loader, fetch data, and then consume it in a component.
Drawing from that tutorial, here's how our refactor would look:
Before: A component with useEffect (ProductDetails.tsx)
After: A route file (routes/products.$productId.tsx)
// src/routes/products.$productId.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
// 1. Define a function to fetch the data. This can be anywhere.
async function fetchProductById(productId: string) {
const res = await fetch(`/api/products/${productId}`);
if (!res.ok) {
throw new Error('Product not found');
}
return res.json();
}
// 2. Create the route definition
export const Route = createFileRoute('/products/$productId')({
// Optional: Validate path params for type safety
parseParams: (params) => ({
productId: z.string().parse(params.productId),
}),
// 3. Define the loader function
loader: async ({ params }) => {
// The loader has access to path params
const product = await fetchProductById(params.productId);
return { product }; // The return value is the loader's data
},
// 4. Define the component for the route
component: ProductPageComponent,
});
// 5. The component is now much simpler
function ProductPageComponent() {
// It gets its data directly from the loader via a type-safe hook
const { product } = Route.useLoaderData();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
Notice the improvements:
- The component is now declarative and has no complex lifecycle logic.
- Data fetching is co-located with the route definition.
- There's no need for
useState,useEffect, or manual loading/error state management for the initial render.
4. The Best of Both Worlds: Integrating with TanStack Query
While the basic loader is powerful, integrating it with TanStack Query is the recommended approach for most applications. This combines the SSR benefits of loaders with TanStack Query's sophisticated client-side caching, background refetching, and state management.
The pattern is slightly different:
- The loader's job is to ensure the data exists in the query cache (
queryClient.ensureQueryData). - The component's job is to read that data from the cache using
useSuspenseQuery.
This creates a seamless data layer that works identically on the server and client.
This segment from "Why I Love TanStack Router" by Better Stack perfectly illustrates the integration between TanStack Router loaders and TanStack Query. It's the pattern you'll likely use most often.
Watch from 08:23 to 09:46. Pay close attention to the use of queryClient.ensureQueryData in the loader and useSuspenseQuery in the component. This is the key pattern.
Here's how our product example would look using this advanced pattern:
// src/routes/products.$productId.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useSuspenseQuery } from '@tanstack/react-query';
// Assume queryOptions are defined elsewhere for reusability
const productQueryOptions = (productId: string) => ({
queryKey: ['products', productId],
queryFn: () => fetchProductById(productId),
});
export const Route = createFileRoute('/products/$productId')({
loader: ({ context: { queryClient }, params }) => {
// The loader ensures the data is fetched and cached.
// It doesn't return the data directly.
return queryClient.ensureQueryData(productQueryOptions(params.productId));
},
component: ProductPageComponent,
});
function ProductPageComponent() {
const { productId } = Route.useParams();
// The component reads from the cache. On SSR, the data is already there.
// On client navigation, this will suspend while the loader (running on the client) fetches.
const { data: product } = useSuspenseQuery(productQueryOptions(productId));
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
This pattern gives you SSR for the initial load and a rich, cached, single-page application experience for all subsequent user interactions. The video below clarifies this dual nature of TanStack Start.
SSR, Preloading, Caching and more with TanStack Start + Query
Let's revisit the Dev Leonardo video to clarify when data is server-rendered versus when it's fetched on the client during navigation.
Watch from 04:02 to 04:58. This part explains how TanStack Start provides SSR on the initial page load but defaults to client-side SPA navigation afterward, giving you the best of both worlds.
Conclusion
Today we've made a significant leap in our SPA-to-SSR migration. By moving data fetching into route loaders, we've fundamentally changed how our application loads, making it faster and more robust.
Key Takeaways:
- Refactor from
useEffect: The primary pattern for migrating data fetching is to move logic fromuseEffecthooks into TanStack Routerloaderfunctions. - Loaders Run First: Loaders execute on the server for initial requests and on the client for subsequent navigations, always before the component renders.
- Consume with
useLoaderData: Components access loader data via the type-safeRoute.useLoaderData()hook, simplifying component logic. - Integrate with TanStack Query: For the most powerful solution, use loaders with
queryClient.ensureQueryDataand components withuseSuspenseQueryto get the benefits of SSR and advanced client-side caching.
In our next lesson, we will dive deeper into a topic we've touched upon: "Isolate and handle browser-specific APIs (e.g., window, document) to prevent server-side errors." Now that our data fetching and component rendering logic runs on the server, ensuring that no browser-only code slips through is more critical than ever.
Can't find a good explanation? Sign up and we'll make it for you
Sign up