| | | | | |

TanStack Router 13 ⚛️ TanStack Query — Query integration

⏱️ Estimated read: 10 min | |

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

ElementPurpose
queryKeyCache identifier
queryFnFetch function
staleTimeFreshness duration

Loader Pattern

MethodBehavior
ensureQueryDataReturns data, throws on error
prefetchQueryReturns void, swallows errors

Component Hooks

HookBehavior
useSuspenseQuerySuspends until data ready
useQueryReturns loading state

Router Configuration

SettingPurpose
defaultPreloadStaleTime: 0Router 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

PitfallWhy It HappensFix
Loading state flashesUsed useQueryUse useSuspenseQuery
Errors not caughtUsed prefetchQueryUse ensureQueryData
Stale dataRouter cache activedefaultPreloadStaleTime: 0
Query not in contextNo root context typeAdd queryClient to context
Data leaks between SSR requestsQueryClient at module scopeCreate 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

ItemValue
Query factoryqueryOptions()
Loader prefetchensureQueryData()
Component readuseSuspenseQuery()
Router settingdefaultPreloadStaleTime: 0
ContextqueryClient in router context
MutationsuseMutation + invalidateQueries
SSR package@tanstack/react-router-ssr-query

Key takeaways:

  • The loader prefetches the query. The ensureQueryData in the loader fetches the data and stores it in the query cache before the component renders. The component reads it with the useSuspenseQuery and renders instantly.
  • The ensureQueryData is for critical data. It returns the data, awaits the fetch, and throws on error. The prefetchQuery is for the background warming and swallows the errors.
  • The defaultPreloadStaleTime: 0 coordinates 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 queryOptions keeps 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 invalidateQueries marks 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.

Was this helpful?


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!