Eliminating SSR Data Grid Flickering with TanStack Router + TanStack Query — Blocking Double Fetching with loaderDeps and staleTime
When building data grids, you'll eventually run into this situation: the table flickers every time you click a page number, a loading spinner appears when you hit a sort button, and you catch the Network tab showing the same request being made on the client that the server already prefetched. At first I thought "this is good enough," but when you actually use it as a user, the difference is quite noticeable.
This post is aimed at frontend developers who have some experience with TanStack Router and TanStack Query. If you're new to either library, it's recommended to skim the official docs first. Here we'll look at how to combine loaderDeps and loader to prefetch URL search param-based data before component rendering, and how to use TanStack Query's staleTime to completely eliminate double fetching in SSR environments. Once you apply this pattern, client re-requests after server prefetch disappear, and data appears immediately on page transitions without a loading spinner.
Core Concepts
Four concepts work together in sequence. loaderDeps declares which URL parameters to use as cache dependencies, loader prefetches data based on those dependencies, staleTime keeps server-fetched data fresh after hydration to prevent re-requests, and dehydrate/HydrationBoundary acts as the bridge that passes the server QueryClient state to the client. Keep this flow in mind as you read, and each concept will click into place much more naturally.
loaderDeps — "Only reload when these parameters change"
In TanStack Router, URL search params can be added freely, but the problem is that if any one search param changes, the loader may re-execute. loaderDeps is the function that gives you precise control over this behavior.
Only when the values in the object returned by loaderDeps change does it treat it as a cache miss and re-run the loader. If the returned value is the same, it uses the cached data as-is. The returned value is passed to the loader function's deps argument, and developers use this deps to explicitly construct the queryKey.
export const Route = createFileRoute('/posts')({
validateSearch: (search) => postsSearchSchema.parse(search),
// Extract only the parameters the loader actually uses
loaderDeps: ({ search: { page, pageSize, sort, filters } }) => ({
page,
pageSize,
sort,
filters,
}),
// The value returned by loaderDeps is injected directly into deps
loader: async ({ context: { queryClient }, deps }) => {
// This is where the developer explicitly constructs the queryKey using deps
await queryClient.ensureQueryData(postsQueryOptions(deps));
},
});Key point: The object returned by
loaderDepsis passed as thedepsargument toloader. The connection to the queryKey is something the developer constructs explicitly, likepostsQueryOptions(deps)— it is not automatic.
loader — The function that prepares data before the component mounts
loader runs before entering a route. It can also run when hovering over a link (preload), but this requires enabling defaultPreload: 'intent' in the router config. When used with TanStack Query, you can choose between two approaches depending on the situation.
| Function | Behavior | Recommended when |
|---|---|---|
ensureQueryData |
Fetches if not in cache and blocks rendering | Data must be present on screen |
prefetchQuery |
Starts fetch without blocking | Streaming / progressive loading |
For most data grid scenarios, ensureQueryData is the natural choice. Having the screen render after data is ready feels much cleaner than briefly showing an empty table.
staleTime — The most important setting for preventing SSR double fetching
Honestly, this is where a lot of people get tripped up. You went through the trouble of prefetching on the server — so why is the client making the same request again?
TanStack Query's default staleTime is 0. The moment the client hydrates data that was prefetched on the server, that data is already judged to be stale. As the component mounts, it says "this data is old, let me fetch it again" and immediately fires off another request.
export const usersQueryOptions = (params: UsersParams) =>
queryOptions({
queryKey: ['users', params],
queryFn: () => fetchUsers(params), // Separate API client function
staleTime: 30 * 1000, // No re-requests for 30 seconds
gcTime: 5 * 60 * 1000, // Cache retained for 5 minutes
});Terminology distinction:
staleTimeis the freshness threshold for data (no re-requests within this time), whilegcTimedetermines how long the cache itself is kept in memory. ThegcTimetimer starts when the component unmounts and the cache subscription ends. The two operate completely independently.
dehydrate / HydrationBoundary — Passing server data to the client
In an SSR environment, the server and client each have their own separate QueryClient. If the cache prefetched on the server isn't passed to the client, the client has no idea that data exists. dehydrate() serializes the server QueryClient into the HTML, and the client's <HydrationBoundary> restores it.
// app/root.tsx (TanStack Start convention)
// In Next.js, dehydrate is called in a Server Component; in Remix, it's called in the loader function
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
export default function Root() {
const dehydratedState = dehydrate(queryClient);
return (
<HydrationBoundary state={dehydratedState}>
<Outlet />
</HydrationBoundary>
);
}Once this pattern is in place, the HTML rendered on the server always matches the data at client hydration time, which also resolves React hydration mismatch warnings.
Practical Application
Example 1: Full setup for a server-side paginated data grid
This is the scenario you'll encounter most often in real work. Let's set up a table where pagination, sorting, and search all need to be reflected in the URL — like an admin page user list.
First, let's check the router config. To use the context: { queryClient } pattern, you need to register the context in createRouter first, and preloading also needs to be enabled at this point.
// router.ts
import { createRouter } from '@tanstack/react-router';
import { QueryClient } from '@tanstack/react-query';
import { routeTree } from './routeTree.gen';
export function createAppRouter(queryClient: QueryClient) {
return createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent', // Enable automatic preload on link hover
});
}It's recommended to separate query option factories into their own file. This is because the loader and the component both need to reference the same queryKey for the cache to be properly shared. This might initially feel like "is this really necessary?" — but after experiencing double fetching caused by the cache not being shared, it becomes a habit.
// queries/users.ts
import { queryOptions } from '@tanstack/react-query';
export interface UsersParams {
page: number;
pageSize: number;
sort: 'name' | 'createdAt' | 'email';
order: 'asc' | 'desc';
search: string;
}
export const usersQueryOptions = (params: UsersParams) =>
queryOptions({
queryKey: ['users', params],
queryFn: () => fetchUsers(params), // Separate API client function
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});Next is the route file. Lock in types with Zod (validateSearch), declare cache dependencies with loaderDeps, then run the prefetch in loader. If you're not familiar with Zod, it's enough to understand that you define a schema with z.object() and use .parse() for validation and default value handling.
// routes/admin/users.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useSuspenseQuery } from '@tanstack/react-query';
import { z } from 'zod';
import { usersQueryOptions } from '../../queries/users';
const searchSchema = z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().default(20),
sort: z.enum(['name', 'createdAt', 'email']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().default(''),
});
export const Route = createFileRoute('/admin/users')({
validateSearch: searchSchema,
loaderDeps: ({ search }) => ({
page: search.page,
pageSize: search.pageSize,
sort: search.sort,
order: search.order,
search: search.search,
}),
loader: async ({ context: { queryClient }, deps }) => {
// Current page — blocking prefetch
await queryClient.ensureQueryData(usersQueryOptions(deps));
// Preemptive prefetch of adjacent pages — runs without await so it doesn't block rendering
if (deps.page > 1) {
queryClient.prefetchQuery(
usersQueryOptions({ ...deps, page: deps.page - 1 })
);
}
queryClient.prefetchQuery(
usersQueryOptions({ ...deps, page: deps.page + 1 })
);
},
component: UsersPage, // Explicitly connect the component to the Route declaration
});
function UsersPage() {
const { page, pageSize, sort, order, search } = Route.useSearch();
const navigate = Route.useNavigate();
const { data } = useSuspenseQuery(
// Same queryOptions used in loader — this is the key to cache sharing
usersQueryOptions({ page, pageSize, sort, order, search })
);
return (
// DataGrid: a table UI component based on TanStack Table or similar
<DataGrid
data={data.rows}
totalCount={data.total}
pagination={{ page, pageSize }}
onPaginationChange={({ page, pageSize }) =>
navigate({ search: (prev) => ({ ...prev, page, pageSize }) })
}
onSortChange={(sort, order) =>
navigate({ search: (prev) => ({ ...prev, sort, order, page: 1 }) })
}
/>
);
}| Code point | Intent |
|---|---|
validateSearch: searchSchema |
Parses search params with Zod — replaces invalid values with defaults |
loaderDeps: ({ search }) => ({ ... }) |
Registers only 5 parameters as cache dependencies — ignores changes to unrelated params |
await ensureQueryData(...) |
Fetches and blocks rendering if current page data is missing |
prefetchQuery(page ± 1) |
Without await — fetches in the background without blocking rendering |
useSuspenseQuery(...) |
Since the loader guarantees data, the suspense fallback is never actually shown |
component: UsersPage |
Explicitly connects the component to the Route declaration |
Example 2: Resetting page to 1 when sort changes
This pattern looks trivial but is easy to miss. If the sort field changes but the page stays at 5, you might request a page that doesn't exist or display incorrect data. I once spent a long time debugging "why is there no data?" because I forgot this.
// Sort change handler — safely handled with functional update for search
const handleSortChange = (sort: string, order: 'asc' | 'desc') => {
navigate({
search: (prev) => ({
...prev,
sort,
order,
page: 1, // Reset to first page when sort field changes
}),
});
};
// Search term changes are handled the same way
const handleSearchChange = (keyword: string) => {
navigate({
search: (prev) => ({
...prev,
search: keyword,
page: 1,
}),
});
};Using navigate's functional update ((prev) => ({ ...prev, ... })) lets you safely preserve the current search params while changing only specific values, avoiding unintentional resets even when multiple parameters are intertwined.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Type-safe URL state | validateSearch + Zod gives search params types that are inferred end-to-end from loader to component |
| Automatic preload | After setting defaultPreload: 'intent', data prefetch starts just by hovering over a link |
| SSR data consistency | dehydrate/hydrate pattern ensures server HTML and client hydration data always match |
| Precise cache control | loaderDeps registers only the actually-used parameters as cache dependencies, preventing unnecessary re-requests |
| Adjacent page prefetch | Pre-fetching next/previous pages in the loader makes page transitions feel instant |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Over-declaring loaderDeps | Including unnecessary parameters causes excessive loader re-execution | Limit to only values actually used in queryFn |
| Missing staleTime | SSR prefetch data becomes stale immediately after hydration, triggering re-requests | Explicitly set staleTime in queryOptions (minimum 30 seconds recommended) |
| Server memory/security | Using a singleton QueryClient in SSR risks data leaking between requests | Create a QueryClient instance per request (see code below) |
| TanStack Start beta | As of 2025, still in beta with possible API changes | Monitor version changelog when applying to production |
"Server memory management" is not just a performance issue. If you create a QueryClient as a global singleton in an SSR environment, different users' requests end up sharing the same cache. This is a serious security problem where personal information or data with different permissions can get mixed together. It is strongly recommended to create a new instance per request.
// ❌ Dangerous: global singleton — data shared between requests
const queryClient = new QueryClient();
// ✅ Safe: new instance created per request
function createQueryClientForRequest() {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 30 * 1000 },
},
});
}The most common mistakes in real-world usage
-
Returning the entire search object like
loaderDeps: ({ search }) => search— If any unrelated parameter changes, unnecessary loader re-execution occurs. At first it seems like "wouldn't putting everything in be simpler?" — but once you experience the table flickering unnecessarily as other UI state gets appended to the URL, your thinking changes. -
Applying SSR without specifying
staleTimeinqueryOptions— Open the Network tab and you can see the client making the same request again immediately after the server prefetch. The symptom feels like "why isn't prefetch working?" but the cause is almost always a missingstaleTime. -
Using different queryOptions objects in the loader's
ensureQueryDataand the component'suseSuspenseQuery— If the queryKey differs even slightly, the cache won't be shared. It's best to export the query options factory function from a single file and reference it identically from both sides.
Closing Thoughts
Declare cache dependencies precisely with loaderDeps, block SSR double fetching with staleTime, and share query options factories between the loader and component — and loading spinners disappear from URL search param-based data grids. I still remember the satisfaction of seeing for the first time in the Network tab that the client re-request after server prefetch had vanished. The moment the URL becomes the single source of truth for state, bookmarks and shareable links follow naturally.
Three steps you can start right now:
- Add
validateSearchandloaderDepsto an existing route — you'll immediately experience type-safe search params being inferred end-to-end from loader to component. - Separate a
queryOptionsfactory file into aqueries/folder and addstaleTime: 30 * 1000— when you introduce SSR later, this file can be reused as-is. - After
ensureQueryDatafor the current page in the loader, fire aprefetchQueryforpage + 1preemptively — you can directly verify the difference in page transition speed by comparing the timings in the Network tab.
References
- TanStack Router — Data Loading Official Docs
- TanStack Router — Search Params Official Docs
- TanStack Router — TanStack Query Integration Guide
- TanStack Router — External Data Loading
- TanStack Query — Server Rendering & Hydration
- TanStack Query — Advanced Server Rendering
- TanStack Query — Prefetching & Router Integration
- TanStack Table — Pagination Guide
- Frontend Masters — Loading Data with TanStack Router: react-query
- Frontend Masters — Loading Data with TanStack Router: Getting Going
- TanStack Start — Selective SSR Guide