|
| 1 | +You are an expert in React, TanStack Router v1, TanStack Query v5, TypeScript, Vite, and building fully type-safe single-page applications. |
| 2 | + |
| 3 | +# React + TanStack Router + TanStack Query Guidelines |
| 4 | + |
| 5 | +## Architecture Overview |
| 6 | +- TanStack Router handles all routing, URL state, and navigation |
| 7 | +- TanStack Query manages all server state, caching, and async data |
| 8 | +- React components are pure UI — they read from Query cache and trigger mutations |
| 9 | +- Loaders bridge Router and Query: they prefetch into the Query cache before render |
| 10 | +- This eliminates loading spinners for route-level data; Suspense handles component-level loading |
| 11 | + |
| 12 | +## Project Setup |
| 13 | +``` |
| 14 | +src/ |
| 15 | + routes/ |
| 16 | + __root.tsx |
| 17 | + index.tsx |
| 18 | + posts/ |
| 19 | + index.tsx |
| 20 | + $postId.tsx |
| 21 | + queries/ ← Query definitions (queryOptions factories) |
| 22 | + posts.ts |
| 23 | + users.ts |
| 24 | + api/ ← API client functions (fetchers) |
| 25 | + posts.ts |
| 26 | + users.ts |
| 27 | + lib/ |
| 28 | + queryClient.ts |
| 29 | + router.ts |
| 30 | + main.tsx |
| 31 | +``` |
| 32 | + |
| 33 | +## QueryClient + Router Setup |
| 34 | +```ts |
| 35 | +// src/lib/queryClient.ts |
| 36 | +import { QueryClient } from '@tanstack/react-query' |
| 37 | + |
| 38 | +export const queryClient = new QueryClient({ |
| 39 | + defaultOptions: { |
| 40 | + queries: { |
| 41 | + staleTime: 1000 * 60, |
| 42 | + retry: (count, error: any) => error?.status !== 404 && count < 2, |
| 43 | + }, |
| 44 | + }, |
| 45 | +}) |
| 46 | +``` |
| 47 | + |
| 48 | +```tsx |
| 49 | +// src/lib/router.ts |
| 50 | +import { createRouter } from '@tanstack/react-router' |
| 51 | +import { routeTree } from '../routeTree.gen' |
| 52 | +import { queryClient } from './queryClient' |
| 53 | + |
| 54 | +export const router = createRouter({ |
| 55 | + routeTree, |
| 56 | + context: { queryClient }, |
| 57 | + defaultPreload: 'intent', |
| 58 | + defaultPreloadStaleTime: 0, |
| 59 | +}) |
| 60 | + |
| 61 | +declare module '@tanstack/react-router' { |
| 62 | + interface Register { |
| 63 | + router: typeof router |
| 64 | + } |
| 65 | +} |
| 66 | +``` |
| 67 | + |
| 68 | +```tsx |
| 69 | +// src/main.tsx |
| 70 | +import { RouterProvider } from '@tanstack/react-router' |
| 71 | +import { QueryClientProvider } from '@tanstack/react-query' |
| 72 | +import { router } from './lib/router' |
| 73 | +import { queryClient } from './lib/queryClient' |
| 74 | + |
| 75 | +ReactDOM.createRoot(document.getElementById('root')!).render( |
| 76 | + <QueryClientProvider client={queryClient}> |
| 77 | + <RouterProvider router={router} context={{ queryClient }} /> |
| 78 | + </QueryClientProvider> |
| 79 | +) |
| 80 | +``` |
| 81 | + |
| 82 | +## Query Definitions (queryOptions factories) |
| 83 | +- Co-locate query key, fetcher, and staleTime in one place |
| 84 | +- Share between Router loaders and component hooks |
| 85 | +```ts |
| 86 | +// src/queries/posts.ts |
| 87 | +import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query' |
| 88 | +import { fetchPost, fetchPosts } from '../api/posts' |
| 89 | + |
| 90 | +export const postKeys = { |
| 91 | + all: ['posts'] as const, |
| 92 | + lists: () => [...postKeys.all, 'list'] as const, |
| 93 | + list: (filters?: PostFilters) => [...postKeys.lists(), filters] as const, |
| 94 | + details: () => [...postKeys.all, 'detail'] as const, |
| 95 | + detail: (id: string) => [...postKeys.details(), id] as const, |
| 96 | +} |
| 97 | + |
| 98 | +export const postDetailQueryOptions = (id: string) => |
| 99 | + queryOptions({ |
| 100 | + queryKey: postKeys.detail(id), |
| 101 | + queryFn: () => fetchPost(id), |
| 102 | + staleTime: 1000 * 60 * 5, |
| 103 | + }) |
| 104 | + |
| 105 | +export const postsListQueryOptions = (filters?: PostFilters) => |
| 106 | + queryOptions({ |
| 107 | + queryKey: postKeys.list(filters), |
| 108 | + queryFn: () => fetchPosts(filters), |
| 109 | + staleTime: 1000 * 60, |
| 110 | + }) |
| 111 | +``` |
| 112 | + |
| 113 | +## Router Loader + Query Integration |
| 114 | +- Loaders call `queryClient.ensureQueryData` — populates cache, renders immediately without spinner |
| 115 | +- Components then call `useQuery` with the same options — reads from cache synchronously |
| 116 | +```tsx |
| 117 | +// src/routes/posts/$postId.tsx |
| 118 | +import { createFileRoute } from '@tanstack/react-router' |
| 119 | +import { useQuery } from '@tanstack/react-query' |
| 120 | +import { postDetailQueryOptions } from '../../queries/posts' |
| 121 | + |
| 122 | +export const Route = createFileRoute('/posts/$postId')({ |
| 123 | + loader: ({ context: { queryClient }, params }) => |
| 124 | + queryClient.ensureQueryData(postDetailQueryOptions(params.postId)), |
| 125 | + |
| 126 | + errorComponent: ({ error }) => <ErrorMessage error={error} />, |
| 127 | + pendingComponent: PostSkeleton, |
| 128 | + component: PostDetail, |
| 129 | +}) |
| 130 | + |
| 131 | +function PostDetail() { |
| 132 | + const { postId } = Route.useParams() |
| 133 | + // data is already in cache from loader — no loading state |
| 134 | + const { data: post } = useQuery(postDetailQueryOptions(postId)) |
| 135 | + |
| 136 | + return <article><h1>{post!.title}</h1></article> |
| 137 | +} |
| 138 | +``` |
| 139 | + |
| 140 | +## Search Params + Query Integration |
| 141 | +- Use TanStack Router search params as the source of truth for filter/pagination state |
| 142 | +- Pass search params into queryOptions to drive query key and fetcher |
| 143 | +```tsx |
| 144 | +// src/routes/posts/index.tsx |
| 145 | +import { createFileRoute, Link } from '@tanstack/react-router' |
| 146 | +import { useQuery } from '@tanstack/react-query' |
| 147 | +import { z } from 'zod' |
| 148 | +import { postsListQueryOptions } from '../../queries/posts' |
| 149 | + |
| 150 | +const searchSchema = z.object({ |
| 151 | + page: z.number().int().min(1).default(1), |
| 152 | + category: z.string().optional(), |
| 153 | +}) |
| 154 | + |
| 155 | +export const Route = createFileRoute('/posts/')({ |
| 156 | + validateSearch: searchSchema, |
| 157 | + loader: ({ context: { queryClient }, location: { search } }) => |
| 158 | + queryClient.ensureQueryData(postsListQueryOptions(search)), |
| 159 | + component: PostsList, |
| 160 | +}) |
| 161 | + |
| 162 | +function PostsList() { |
| 163 | + const search = Route.useSearch() |
| 164 | + const navigate = Route.useNavigate() |
| 165 | + const { data: posts } = useQuery(postsListQueryOptions(search)) |
| 166 | + |
| 167 | + return ( |
| 168 | + <div> |
| 169 | + {posts?.map(post => ( |
| 170 | + <Link key={post.id} to="/posts/$postId" params={{ postId: post.id }}> |
| 171 | + {post.title} |
| 172 | + </Link> |
| 173 | + ))} |
| 174 | + <button onClick={() => navigate({ search: { ...search, page: search.page + 1 } })}> |
| 175 | + Next Page |
| 176 | + </button> |
| 177 | + </div> |
| 178 | + ) |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +## Mutations |
| 183 | +```tsx |
| 184 | +import { useMutation, useQueryClient } from '@tanstack/react-query' |
| 185 | +import { useNavigate } from '@tanstack/react-router' |
| 186 | +import { postKeys } from '../../queries/posts' |
| 187 | + |
| 188 | +function CreatePostForm() { |
| 189 | + const queryClient = useQueryClient() |
| 190 | + const navigate = useNavigate() |
| 191 | + |
| 192 | + const mutation = useMutation({ |
| 193 | + mutationFn: createPost, |
| 194 | + onSuccess: (newPost) => { |
| 195 | + // Populate detail cache immediately |
| 196 | + queryClient.setQueryData(postKeys.detail(newPost.id), newPost) |
| 197 | + // Invalidate list queries |
| 198 | + queryClient.invalidateQueries({ queryKey: postKeys.lists() }) |
| 199 | + // Navigate to new post (no loading — cache is warm) |
| 200 | + navigate({ to: '/posts/$postId', params: { postId: newPost.id } }) |
| 201 | + }, |
| 202 | + }) |
| 203 | + |
| 204 | + return (/* form JSX */) |
| 205 | +} |
| 206 | +``` |
| 207 | + |
| 208 | +## Authentication Pattern |
| 209 | +```tsx |
| 210 | +// src/routes/__root.tsx |
| 211 | +import { createRootRouteWithContext } from '@tanstack/react-router' |
| 212 | + |
| 213 | +export interface RouterContext { |
| 214 | + queryClient: QueryClient |
| 215 | + auth: { isAuthenticated: boolean; user: User | null } |
| 216 | +} |
| 217 | + |
| 218 | +export const Route = createRootRouteWithContext<RouterContext>()({ |
| 219 | + component: RootLayout, |
| 220 | +}) |
| 221 | + |
| 222 | +// src/routes/_auth.tsx (pathless layout for protected routes) |
| 223 | +export const Route = createFileRoute('/_auth')({ |
| 224 | + beforeLoad: ({ context }) => { |
| 225 | + if (!context.auth.isAuthenticated) { |
| 226 | + throw redirect({ to: '/login', search: { redirect: location.pathname } }) |
| 227 | + } |
| 228 | + }, |
| 229 | +}) |
| 230 | +``` |
| 231 | + |
| 232 | +## Prefetching on Hover |
| 233 | +```tsx |
| 234 | +function PostCard({ post }: { post: Post }) { |
| 235 | + const queryClient = useQueryClient() |
| 236 | + return ( |
| 237 | + <Link |
| 238 | + to="/posts/$postId" |
| 239 | + params={{ postId: post.id }} |
| 240 | + onMouseEnter={() => queryClient.prefetchQuery(postDetailQueryOptions(post.id))} |
| 241 | + > |
| 242 | + {post.title} |
| 243 | + </Link> |
| 244 | + ) |
| 245 | +} |
| 246 | +``` |
| 247 | + |
| 248 | +## DevTools (Development Only) |
| 249 | +```tsx |
| 250 | +// In __root.tsx |
| 251 | +import { TanStackRouterDevtools } from '@tanstack/router-devtools' |
| 252 | +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' |
| 253 | + |
| 254 | +// Inside component |
| 255 | +{import.meta.env.DEV && ( |
| 256 | + <> |
| 257 | + <TanStackRouterDevtools position="bottom-left" /> |
| 258 | + <ReactQueryDevtools buttonPosition="bottom-right" /> |
| 259 | + </> |
| 260 | +)} |
| 261 | +``` |
| 262 | + |
| 263 | +## Key Rules |
| 264 | +- Always define `queryOptions` outside of components — not inline in `useQuery()` |
| 265 | +- Never use `useEffect` to fetch data — use loaders or `useQuery` |
| 266 | +- Always type router context — `declare module '@tanstack/react-router'` registration is required |
| 267 | +- Search params are the only source of truth for URL-driven filter state |
| 268 | +- Mutations should `setQueryData` + `invalidateQueries`, not just invalidate, for instant UI feedback |
0 commit comments