react tanstack router query
via PatrickJS/awesome-cursorrules
Zero-loading-spinner routing with TanStack Router v1 and Query v5 for type-safe React SPAs
What is react tanstack router query?
Combines TanStack Router for URL/navigation state with TanStack Query for server state management. Uses loaders to prefetch data into the Query cache before rendering, eliminating loading spinners at the route level. Suspense handles component-level async UI.
- Prefetch route-level data via loaders with queryClient.ensureQueryData for instant renders
- Manage server state and caching with TanStack Query v5 queryOptions factories
- Drive queries from Router search params as the single source of truth for filters and pagination
- Invalidate and update query caches on mutations for instant UI feedback without re-fetching
- Integrate authentication via Router context with protected route layouts and redirects
- Prefetch on link hover to warm the cache before navigation
Applies to
File patterns this rule matches.
Rule definition (reference)
Source of truth, from the repository.
You are an expert in React, TanStack Router v1, TanStack Query v5, TypeScript, Vite, and building fully type-safe single-page applications.
React + TanStack Router + TanStack Query Guidelines
Architecture Overview
- TanStack Router handles all routing, URL state, and navigation
- TanStack Query manages all server state, caching, and async data
- React components are pure UI — they read from Query cache and trigger mutations
- Loaders bridge Router and Query: they prefetch into the Query cache before render
- This eliminates loading spinners for route-level data; Suspense handles component-level loading
Project Setup
src/
routes/
__root.tsx
index.tsx
posts/
index.tsx
$postId.tsx
queries/ ← Query definitions (queryOptions factories)
posts.ts
users.ts
api/ ← API client functions (fetchers)
posts.ts
users.ts
lib/
queryClient.ts
router.ts
main.tsx
QueryClient + Router Setup
// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
retry: (count, error: any) => error?.status !== 404 && count < 2,
},
},
})
// src/lib/router.ts
import { createRouter } from '@tanstack/react-router'
import { routeTree } from '../routeTree.gen'
import { queryClient } from './queryClient'
export const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// src/main.tsx
import { RouterProvider } from '@tanstack/react-router'
import { QueryClientProvider } from '@tanstack/react-query'
import { router } from './lib/router'
import { queryClient } from './lib/queryClient'
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} context={{ queryClient }} />
</QueryClientProvider>
)
Query Definitions (queryOptions factories)
- Co-locate query key, fetcher, and staleTime in one place
- Share between Router loaders and component hooks
// src/queries/posts.ts
import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query'
import { fetchPost, fetchPosts } from '../api/posts'
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters?: PostFilters) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (id: string) => [...postKeys.details(), id] as const,
}
export const postDetailQueryOptions = (id: string) =>
queryOptions({
queryKey: postKeys.detail(id),
queryFn: () => fetchPost(id),
staleTime: 1000 * 60 * 5,
})
export const postsListQueryOptions = (filters?: PostFilters) =>
queryOptions({
queryKey: postKeys.list(filters),
queryFn: () => fetchPosts(filters),
staleTime: 1000 * 60,
})
Router Loader + Query Integration
- Loaders call
queryClient.ensureQueryData— populates cache, renders immediately without spinner - Components then call
useQuerywith the same options — reads from cache synchronously
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { postDetailQueryOptions } from '../../queries/posts'
export const Route = createFileRoute('/posts/$postId')({
loader: ({ context: { queryClient }, params }) =>
queryClient.ensureQueryData(postDetailQueryOptions(params.postId)),
errorComponent: ({ error }) => <ErrorMessage error={error} />,
pendingComponent: PostSkeleton,
component: PostDetail,
})
function PostDetail() {
const { postId } = Route.useParams()
// data is already in cache from loader — no loading state
const { data: post } = useQuery(postDetailQueryOptions(postId))
return <article><h1>{post!.title}</h1></article>
}
Search Params + Query Integration
- Use TanStack Router search params as the source of truth for filter/pagination state
- Pass search params into queryOptions to drive query key and fetcher
// src/routes/posts/index.tsx
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { postsListQueryOptions } from '../../queries/posts'
const searchSchema = z.object({
page: z.number().int().min(1).default(1),
category: z.string().optional(),
})
export const Route = createFileRoute('/posts/')({
validateSearch: searchSchema,
loader: ({ context: { queryClient }, location: { search } }) =>
queryClient.ensureQueryData(postsListQueryOptions(search)),
component: PostsList,
})
function PostsList() {
const search = Route.useSearch()
const navigate = Route.useNavigate()
const { data: posts } = useQuery(postsListQueryOptions(search))
return (
<div>
{posts?.map(post => (
<Link key={post.id} to="/posts/$postId" params={{ postId: post.id }}>
{post.title}
</Link>
))}
<button onClick={() => navigate({ search: { ...search, page: search.page + 1 } })}>
Next Page
</button>
</div>
)
}
Mutations
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { postKeys } from '../../queries/posts'
function CreatePostForm() {
const queryClient = useQueryClient()
const navigate = useNavigate()
const mutation = useMutation({
mutationFn: createPost,
onSuccess: (newPost) => {
// Populate detail cache immediately
queryClient.setQueryData(postKeys.detail(newPost.id), newPost)
// Invalidate list queries
queryClient.invalidateQueries({ queryKey: postKeys.lists() })
// Navigate to new post (no loading — cache is warm)
navigate({ to: '/posts/$postId', params: { postId: newPost.id } })
},
})
return (/* form JSX */)
}
Authentication Pattern
// src/routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router'
export interface RouterContext {
queryClient: QueryClient
auth: { isAuthenticated: boolean; user: User | null }
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
})
// src/routes/_auth.tsx (pathless layout for protected routes)
export const Route = createFileRoute('/_auth')({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login', search: { redirect: location.pathname } })
}
},
})
Prefetching on Hover
function PostCard({ post }: { post: Post }) {
const queryClient = useQueryClient()
return (
<Link
to="/posts/$postId"
params={{ postId: post.id }}
onMouseEnter={() => queryClient.prefetchQuery(postDetailQueryOptions(post.id))}
>
{post.title}
</Link>
)
}
DevTools (Development Only)
// In __root.tsx
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
// Inside component
{import.meta.env.DEV && (
<>
<TanStackRouterDevtools position="bottom-left" />
<ReactQueryDevtools buttonPosition="bottom-right" />
</>
)}
Key Rules
- Always define
queryOptionsoutside of components — not inline inuseQuery() - Never use
useEffectto fetch data — use loaders oruseQuery - Always type router context —
declare module '@tanstack/react-router'registration is required - Search params are the only source of truth for URL-driven filter state
- Mutations should
setQueryData+invalidateQueries, not just invalidate, for instant UI feedback
Related rules
Senior full-stack TypeScript, React, Node.js guidance with clean architecture, testing, and WHY-oriented reasoning.
Quantitative factor research skills for designing, evaluating, and mining alpha factors in equities markets.
Android development with Jetpack Compose, clean architecture, and Material Design 3.
Angular development with Novo Elements UI library using standalone components.
Expert Angular 18 + TypeScript development with Jest, emphasizing clean code and performance.
Manage Kubernetes clusters, add-ons, stacks, and credentials via the Ankra CLI platform.