TanStack Router 13 ⚛️ TanStack Query — Query integration
The previous chapters covered the route loaders, which fetch the data before the route renders, and the pending states, which show the loading UI. This chapter introduces the integration with TanStack Query, the dedicated data-fetching library that provides the caching, the background refetching, the mutations, and the invalidation. TanStack Router has its own loader mechanism, but TanStack Query provides a richer data layer for the server-owned data. The integration is the pattern of using the router’s loader to prefetch the query, and the component to subscribe to the query. The critical distinction is between the router’s cache and the query’s cache. The router has a built-in cache for the loader data, and the query has its own cache for the server state. Running both without coordination leads to the stale data and the confusion about which is the source of truth. The defaultPreloadStaleTime: 0 setting is the coordination point: it tells the router to defer to TanStack Query for the freshness decisions .
Key point: The recommended pattern is to define the queryOptions (the key and the function together), to prefetch the query in the router’s loader with queryClient.ensureQueryData(), and to read the data in the component with useSuspenseQuery(). The ensureQueryData respects the cache, awaits the data when missing, and throws on error, which allows the router’s errorComponent to handle the failures. The prefetchQuery is the alternative for the background warming without the blocking, but it swallows the errors, so it is not suitable for the route-critical data .
Why the Query integration requires a specific mental model
The dual-cache problem. The router has a cache for the loader results. The query has a cache for the query results. If both are active, the router may serve the stale loader data while the query has the fresh data. The defaultPreloadStaleTime: 0 setting disables the router’s cache for the preload, and lets the query decide whether the data is fresh. The query becomes the single source of truth for the server state .
The ensureQueryData versus prefetchQuery problem. The ensureQueryData returns the data, awaits the fetch when the cache is missing, and throws on error. The prefetchQuery returns void, does not throw, and swallows the errors. The ensureQueryData is the correct choice for the loader that must have the data before rendering. The prefetchQuery is the correct choice for the secondary data that can load after the page renders .
The useSuspenseQuery versus useQuery problem. The useSuspenseQuery suspends the component until the data is ready. The useQuery returns the isLoading and the isPending flags, and the component renders the loading state. The useSuspenseQuery is the correct choice for the data that the loader prefetched, because the data is already in the cache, and the component renders instantly. The useQuery is the correct choice for the data that is not critical to the initial render .
The query options problem. The queryOptions helper is the factory that keeps the key and the function together. The loader and the component use the same options, which ensures the same cache entry. The queryOptions provides the type inference for the data in both the loader and the component. The pattern is to define the options in a shared file, import them in the loader, and import them in the component .
The SSR problem. For the server-side rendering, the @tanstack/react-router-ssr-query package provides the automatic dehydration and hydration. The setupRouterSsrQueryIntegration wires the router and the query, and the server-fetched data is transferred to the client without the duplicate fetch. The client must create the QueryClient inside the getRouter function, not at the module scope, to prevent the data leaking between the requests .
The trade-off. The Query integration is more complex than the plain loader. The query brings the caching, the mutations, the background refetching, and the devtools. The plain loader is simpler but limited to the initial fetch. The integration is the correct choice for the application that needs the data to stay fresh after the initial render.
a. The basic pattern: prefetch in the loader, read with useSuspenseQuery
The loader prefetches the query. The component reads the data with the suspense hook.
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
const postsQueryOptions = queryOptions({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then((r) => r.json()),
staleTime: 30_000,
})
export const Route = createFileRoute('/posts')({
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(postsQueryOptions)
},
component: PostsPage,
})
function PostsPage() {
const { data: posts } = useSuspenseQuery(postsQueryOptions)
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
The loader runs the ensureQueryData, which fetches the data and stores it in the query cache. The useSuspenseQuery reads the cache. Because the loader already populated the cache, the component renders instantly without the loading state. The staleTime controls how long the data is considered fresh.
The context.queryClient is available because the QueryClient is passed to the createRouter context. The root route must declare the queryClient in the context type for the type safety.
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
}>()({
component: () => <Outlet />,
})
The createRootRouteWithContext<{ queryClient: QueryClient }>() declares the context. The loader accesses it via context.queryClient.
b. The query options and the params
The query options accept the params. The key includes the param value. The loader passes the param from the route.
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: () => fetch(`/api/posts/${postId}`).then((r) => r.json()),
staleTime: 30_000,
})
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context }) => {
await context.queryClient.ensureQueryData(postQueryOptions(params.postId))
},
component: PostDetail,
})
function PostDetail() {
const { postId } = Route.useParams()
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
return <h1>{post.title}</h1>
}
The postQueryOptions is the function that returns the options. The loader calls it with params.postId. The component calls it with Route.useParams().postId. The same key ensures the same cache entry. The staleTime prevents the immediate refetch when the component mounts.
c. The mutations and the invalidation
The mutations are handled by TanStack Query’s useMutation. The onSuccess invalidates the queries that the mutation affects. The router’s loader re-runs when the query is invalidated, and the updated data appears.
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreatePost() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newPost: { title: string }) =>
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
headers: { 'Content-Type': 'application/json' },
}).then((r) => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
return (
<button onClick={() => mutation.mutate({ title: 'New Post' })}>
Create
</button>
)
}
The invalidateQueries marks the ['posts'] query as stale. The next time the loader runs, the ensureQueryData refetches the data. The useSuspenseQuery in the component re-renders with the new data.
The navigate after the mutation is the optional. The invalidation alone is sufficient if the user stays on the same route. The navigate is the choice if the mutation should redirect to the list.
Complete Example Session
# ============================================
# PART 1: DEFINE QUERY OPTIONS
# ============================================
const postsQueryOptions = queryOptions({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then((r) => r.json()),
staleTime: 30_000,
})
# ============================================
# PART 2: ROOT CONTEXT
# ============================================
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
}>()({ component: () => <Outlet /> })
# ============================================
# PART 3: ROUTER SETUP
# ============================================
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreloadStaleTime: 0,
})
# ============================================
# PART 4: LOADER PREFETCH
# ============================================
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(postsQueryOptions)
}
# ============================================
# PART 5: COMPONENT READ
# ============================================
const { data: posts } = useSuspenseQuery(postsQueryOptions)
# ============================================
# PART 6: PARAMETERIZED QUERY
# ============================================
const postQueryOptions = (id: string) =>
queryOptions({
queryKey: ['posts', id],
queryFn: () => fetch(`/api/posts/${id}`).then((r) => r.json()),
})
# ============================================
# PART 7: PARAM LOADER
# ============================================
loader: async ({ params, context }) => {
await context.queryClient.ensureQueryData(postQueryOptions(params.postId))
}
# ============================================
# PART 8: MUTATION
# ============================================
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] }),
})
# ============================================
# PART 9: VERIFY
# ============================================
# Navigate to /posts → loader prefetches → component renders instantly.
# Submit mutation → query invalidated → loader re-runs → updated data.
The nine parts trace the integration: query options, root context, router setup, loader prefetch, component read, parameterized query, param loader, mutation, verification.
Quick Reference
Query Options
| Element | Purpose |
|---|---|
queryKey | Cache identifier |
queryFn | Fetch function |
staleTime | Freshness duration |
Loader Pattern
| Method | Behavior |
|---|---|
ensureQueryData | Returns data, throws on error |
prefetchQuery | Returns void, swallows errors |
Component Hooks
| Hook | Behavior |
|---|---|
useSuspenseQuery | Suspends until data ready |
useQuery | Returns loading state |
Router Configuration
| Setting | Purpose |
|---|---|
defaultPreloadStaleTime: 0 | Router defers to Query |
context: { queryClient } | Access in loader |
Best Practices
✅ Do This:
await context.queryClient.ensureQueryData(queryOptions)
<!-- Use ensureQueryData for critical data --> # ✅
const { data } = useSuspenseQuery(queryOptions)
<!-- Use useSuspenseQuery after prefetch --> # ✅
defaultPreloadStaleTime: 0
<!-- Let Query own freshness --> # ✅
❌ Don’t Do This:
# Use prefetchQuery for critical data
<!-- It swallows errors --> # ❌
# Use useQuery when the loader prefetched
<!-- The loading state flashes --> # ❌
# Keep the router cache active alongside Query
<!-- The two caches conflict --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Loading state flashes | Used useQuery | Use useSuspenseQuery |
| Errors not caught | Used prefetchQuery | Use ensureQueryData |
| Stale data | Router cache active | defaultPreloadStaleTime: 0 |
| Query not in context | No root context type | Add queryClient to context |
| Data leaks between SSR requests | QueryClient at module scope | Create inside getRouter |
Real-World Examples
1. Query Options
queryOptions({ queryKey: ['posts'], queryFn: fetchPosts })
2. Root Context
createRootRouteWithContext<{ queryClient: QueryClient }>()
3. Router Context
createRouter({ routeTree, context: { queryClient } })
4. Loader Prefetch
await context.queryClient.ensureQueryData(options)
5. Component Read
const { data } = useSuspenseQuery(options)
6. Parameterized Query
queryKey: ['posts', id]
7. Mutation
useMutation({ mutationFn, onSuccess: invalidate })
8. Invalidation
queryClient.invalidateQueries({ queryKey: ['posts'] })
9. Preload Stale Time
defaultPreloadStaleTime: 0
10. SSR Setup
setupRouterSsrQueryIntegration({ router, queryClient })
Visual
Data Flow
┌──────────────────────────────────────────────┐
│ Navigation to /posts │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Router loader runs │
│ → ensureQueryData(options) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Query cache check │
│ ├── Fresh → return cached data │
│ └── Stale → fetch and cache │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Component renders with useSuspenseQuery │
│ → Data already in cache → no loading │
└──────────────────────────────────────────────┘
Mutation Flow
┌──────────────────────────────────────────────┐
│ User submits mutation │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ useMutation onSuccess │
│ → invalidateQueries({ queryKey: ['posts'] })│
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Next loader run → ensureQueryData refetches │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Component re-renders with new data │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Query factory | queryOptions() |
| Loader prefetch | ensureQueryData() |
| Component read | useSuspenseQuery() |
| Router setting | defaultPreloadStaleTime: 0 |
| Context | queryClient in router context |
| Mutations | useMutation + invalidateQueries |
| SSR package | @tanstack/react-router-ssr-query |
Key takeaways:
- The loader prefetches the query. The
ensureQueryDatain the loader fetches the data and stores it in the query cache before the component renders. The component reads it with theuseSuspenseQueryand renders instantly. - The
ensureQueryDatais for critical data. It returns the data, awaits the fetch, and throws on error. TheprefetchQueryis for the background warming and swallows the errors. - The
defaultPreloadStaleTime: 0coordinates the caches. The router defers to the query for the freshness decisions. The query is the single source of truth for the server state. - The
queryOptionskeeps the key and the function together. The same options are used in the loader and the component, which ensures the same cache entry and the type safety. - The mutations invalidate the queries. The
invalidateQueriesmarks the affected queries as stale, which triggers the refetch on the next loader run or the component mount.
Remember: The loader is the prefetch. The query is the cache. The ensureQueryData is the bridge. The useSuspenseQuery is the read. The defaultPreloadStaleTime: 0 is the coordination. The exam tests the prefetch pattern and the cache coordination.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!