-
-
Notifications
You must be signed in to change notification settings - Fork 3.3k
feat: added React TanStack Router and Query cursorrules #252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,117 @@ | ||||||
| --- | ||||||
| description: React SPA with TanStack Router v1 + TanStack Query v5 — the definitive pattern for zero-loading-spinner routing, type-safe URLs, and cache-first data | ||||||
| globs: ["src/routes/**/*", "src/queries/**/*", "src/lib/router.ts", "src/lib/queryClient.ts"] | ||||||
| alwaysApply: false | ||||||
| --- | ||||||
|
|
||||||
| You are an expert in React, TanStack Router v1, TanStack Query v5, TypeScript, and Vite. | ||||||
|
|
||||||
| ## Architecture | ||||||
| - TanStack Router: routing, URL state, navigation | ||||||
| - TanStack Query: server state, caching, mutations | ||||||
| - Loader = bridge: prefetches into Query cache before render → zero loading spinners for route data | ||||||
| - Components are pure UI: read from Query cache, trigger mutations | ||||||
|
|
||||||
| ## Setup | ||||||
| ```ts | ||||||
| // src/lib/queryClient.ts | ||||||
| export const queryClient = new QueryClient({ | ||||||
| defaultOptions: { queries: { staleTime: 60_000 } }, | ||||||
| }) | ||||||
|
|
||||||
| // src/lib/router.ts | ||||||
| export const router = createRouter({ | ||||||
| routeTree, | ||||||
| context: { queryClient }, | ||||||
| defaultPreload: 'intent', | ||||||
| defaultPreloadStaleTime: 0, | ||||||
| }) | ||||||
|
|
||||||
| declare module '@tanstack/react-router' { | ||||||
| interface Register { router: typeof router } | ||||||
| } | ||||||
|
|
||||||
| // src/main.tsx | ||||||
| <QueryClientProvider client={queryClient}> | ||||||
| <RouterProvider router={router} context={{ queryClient }} /> | ||||||
| </QueryClientProvider> | ||||||
| ``` | ||||||
|
|
||||||
| ## Query Definitions | ||||||
| ```ts | ||||||
| // src/queries/posts.ts | ||||||
| export const postKeys = { | ||||||
| all: ['posts'] as const, | ||||||
| detail: (id: string) => [...postKeys.all, 'detail', id] as const, | ||||||
| list: (f?: PostFilters) => [...postKeys.all, 'list', f] as const, | ||||||
| } | ||||||
|
|
||||||
| export const postQueryOptions = (id: string) => | ||||||
| queryOptions({ queryKey: postKeys.detail(id), queryFn: () => fetchPost(id) }) | ||||||
|
|
||||||
| export const postsQueryOptions = (filters?: PostFilters) => | ||||||
| queryOptions({ queryKey: postKeys.list(filters), queryFn: () => fetchPosts(filters) }) | ||||||
| ``` | ||||||
|
|
||||||
| ## Loader + Component (zero loading state) | ||||||
| ```tsx | ||||||
| export const Route = createFileRoute('/posts/$postId')({ | ||||||
| loader: ({ context: { queryClient }, params }) => | ||||||
| queryClient.ensureQueryData(postQueryOptions(params.postId)), | ||||||
| component: PostDetail, | ||||||
| }) | ||||||
|
|
||||||
| function PostDetail() { | ||||||
| const { postId } = Route.useParams() | ||||||
| const { data: post } = useQuery(postQueryOptions(postId)) // always in cache from loader | ||||||
| return <h1>{post!.title}</h1> | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| ## Search Params → Query Key | ||||||
| ```tsx | ||||||
| const searchSchema = z.object({ page: z.number().default(1), q: z.string().optional() }) | ||||||
|
|
||||||
| export const Route = createFileRoute('/posts/')({ | ||||||
| validateSearch: searchSchema, | ||||||
| loader: ({ context: { queryClient }, location: { search } }) => | ||||||
| queryClient.ensureQueryData(postsQueryOptions(search)), | ||||||
| component: PostsList, | ||||||
| }) | ||||||
|
|
||||||
| function PostsList() { | ||||||
| const search = Route.useSearch() | ||||||
| const { data } = useQuery(postsQueryOptions(search)) | ||||||
| // ... | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| ## Mutations | ||||||
| ```tsx | ||||||
| const mutation = useMutation({ | ||||||
| mutationFn: createPost, | ||||||
| onSuccess: (newPost) => { | ||||||
| queryClient.setQueryData(postKeys.detail(newPost.id), newPost) // warm cache | ||||||
| queryClient.invalidateQueries({ queryKey: postKeys.list() }) | ||||||
|
||||||
| queryClient.invalidateQueries({ queryKey: postKeys.list() }) | |
| queryClient.invalidateQueries({ queryKey: postKeys.lists() }) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,268 @@ | ||||||||||||||||||
| 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 | ||||||||||||||||||
| ```ts | ||||||||||||||||||
| // 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, | ||||||||||||||||||
| }, | ||||||||||||||||||
| }, | ||||||||||||||||||
| }) | ||||||||||||||||||
| ``` | ||||||||||||||||||
|
|
||||||||||||||||||
| ```tsx | ||||||||||||||||||
| // 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 | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| ``` | ||||||||||||||||||
|
|
||||||||||||||||||
| ```tsx | ||||||||||||||||||
| // 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 | ||||||||||||||||||
| ```ts | ||||||||||||||||||
| // 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 `useQuery` with the same options — reads from cache synchronously | ||||||||||||||||||
| ```tsx | ||||||||||||||||||
| // 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 | ||||||||||||||||||
| ```tsx | ||||||||||||||||||
| // 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 | ||||||||||||||||||
| ```tsx | ||||||||||||||||||
| 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 | ||||||||||||||||||
| ```tsx | ||||||||||||||||||
| // 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 }) => { | ||||||||||||||||||
|
||||||||||||||||||
| beforeLoad: ({ context }) => { | |
| beforeLoad: ({ context, location }) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Auth guard example references location out of scope.
beforeLoad only destructures context, but uses location.pathname. This will fail if copied as-is.
Suggested fix
-export const Route = createFileRoute('/_auth')({
- beforeLoad: ({ context }) => {
+export const Route = createFileRoute('/_auth')({
+ beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login', search: { redirect: location.pathname } })
}
},
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| beforeLoad: ({ context }) => { | |
| if (!context.auth.isAuthenticated) { | |
| throw redirect({ to: '/login', search: { redirect: location.pathname } }) | |
| } | |
| beforeLoad: ({ context, location }) => { | |
| if (!context.auth.isAuthenticated) { | |
| throw redirect({ to: '/login', search: { redirect: location.pathname } }) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@rules/react-tanstack-router-query-cursorrules-prompt-file/.cursorrules`
around lines 224 - 227, The beforeLoad hook uses location.pathname which is out
of scope; update the beforeLoad signature to destructure the location (e.g.,
beforeLoad: ({ context, location }) => { ... }) or otherwise obtain the location
from the available params, then pass location.pathname into redirect; ensure the
redirect call (redirect({ to: '/login', search: { redirect: location.pathname }
})) uses the in-scope location variable and still checks
context.auth.isAuthenticated before throwing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: PatrickJS/awesome-cursorrules
Length of output: 2327
🌐 Web query:
TanStack Query invalidateQueries queryKey matching behavior undefined filters💡 Result:
TanStack Query's invalidateQueries uses partial prefix matching by default for queryKey filters, implemented via partialMatchKey and matchQuery utilities. For arrays, the filter key must be a strict prefix of the query key. For objects in keys, matching iterates over Object.keys of the filter object, checking if those properties exist in the query key object (regardless of value). Key behavior with undefined filters: - When hashing query keys for caching (via JSON.stringify-like hashKey), undefined object properties are omitted, so {filter: undefined} hashes the same as {}. - However, invalidateQueries matching compares the raw queryKey structures without hashing. A filter with explicit {filter: undefined} only matches queries where the 'filter' property is present (even if undefined there), but does not prefix-match queries lacking the 'filter' property entirely. This leads to unintuitive behavior: invalidateQueries([{..., filter: undefined}]) matches cached queries from [{...}] (no filter) in some cases but not consistently, as noted in open GitHub issue
#3741. Workarounds: - Avoid undefined in query keys; use null, {}, or omit the property. - Use query key factories that conditionally include properties only when defined. - For explicit no-filter invalidation, use a shorter prefix key or predicate function. - Custom queryKeyHashFn can normalize undefined to null globally, but doesn't affect raw matching. Official docs recommend including all dependencies in keys but don't explicitly address undefined handling beyond hashing notes. Behavior unchanged in v5 as of 2026.Citations:
List-query invalidation pattern is too narrow and can miss filtered caches.
postKeys.list(filters)includes the filter object in the key, creating queries like['posts', 'list', {page: 1}]. CallinginvalidateQueries({ queryKey: postKeys.list() })creates a filter key['posts', 'list', undefined], which only matches queries with undefined as the third element—not filtered variants. This leaves stale list data after mutations. Use a separate prefix factory for broad invalidation:Suggested fix
export const postKeys = { all: ['posts'] as const, + lists: () => [...postKeys.all, 'list'] as const, detail: (id: string) => [...postKeys.all, 'detail', id] as const, - list: (f?: PostFilters) => [...postKeys.all, 'list', f] as const, + list: (f?: PostFilters) => [...postKeys.lists(), f] as const, }Then at line 95, change:
🤖 Prompt for AI Agents