Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ By creating a `.cursorrules` file in your project's root directory, you can leve
- [React (Redux, TypeScript)](./rules/react-redux-typescript-cursorrules-prompt-file/.cursorrules) - Cursor rules for React development with Redux and TypeScript integration.
- [React (MobX)](./rules/react-mobx-cursorrules-prompt-file/.cursorrules) - Cursor rules for React development with MobX integration.
- [React (React Query)](./rules/react-query-cursorrules-prompt-file/.cursorrules) - Cursor rules for React development with React Query integration.
- [React (TanStack Router + Query)](./rules/react-tanstack-router-query-cursorrules-prompt-file/.cursorrules) - Cursor rules for React SPAs combining TanStack Router v1 and TanStack Query v5 for zero-loading-spinner routing and type-safe server state.

### Database and API

Expand Down
117 changes: 117 additions & 0 deletions rules-new/react-tanstack-router-query.mdc
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,
}
Comment on lines +43 to +47
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check the file exists and read the relevant sections
cat -n rules-new/react-tanstack-router-query.mdc | sed -n '40,100p'

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}]. Calling invalidateQueries({ 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:

-    queryClient.invalidateQueries({ queryKey: postKeys.list() })
+    queryClient.invalidateQueries({ queryKey: postKeys.lists() })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rules-new/react-tanstack-router-query.mdc` around lines 43 - 47,
postKeys.list currently embeds the filter object in the query key which prevents
broad invalidation; add a new factory postKeys.listPrefix = () =>
[...postKeys.all, 'list'] as const and change postKeys.list to return
[...postKeys.listPrefix(), f] as const, then update any invalidation calls to
use invalidateQueries({ queryKey: postKeys.listPrefix() }) so all filtered and
unfiltered list queries are matched and invalidated.


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() })
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the prompt-file version: postKeys.list() includes an explicit undefined segment in the query key, so invalidateQueries({ queryKey: postKeys.list() }) won’t invalidate list queries created with filter objects. Use a stable prefix key (e.g. postKeys.lists() / postKeys.all) for invalidation.

Suggested change
queryClient.invalidateQueries({ queryKey: postKeys.list() })
queryClient.invalidateQueries({ queryKey: postKeys.lists() })

Copilot uses AI. Check for mistakes.
navigate({ to: '/posts/$postId', params: { postId: newPost.id } }) // instant — no spinner
},
})
```

## Hover Prefetching
```tsx
<Link
to="/posts/$postId"
params={{ postId: post.id }}
onMouseEnter={() => queryClient.prefetchQuery(postQueryOptions(post.id))}
>
{post.title}
</Link>
```

## Key Rules
- Always define `queryOptions` outside components — never inline inside `useQuery()`
- Never use `useEffect` for data fetching — use loaders or `useQuery`
- Search params are the single source of truth for filter/pagination state
- After mutations: `setQueryData` + `invalidateQueries` for instant UI feedback
- `declare module '@tanstack/react-router'` router registration is required for full type safety
268 changes: 268 additions & 0 deletions rules/react-tanstack-router-query-cursorrules-prompt-file/.cursorrules
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 }) => {
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the beforeLoad example, location is referenced (location.pathname) but it isn’t in scope in this function signature, so this snippet won’t compile/run as written. Destructure location from the beforeLoad args (or use the router-provided location from the args) before using pathname in the redirect search param.

Suggested change
beforeLoad: ({ context }) => {
beforeLoad: ({ context, location }) => {

Copilot uses AI. Check for mistakes.
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login', search: { redirect: location.pathname } })
}
Comment on lines +224 to +227
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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.

},
})
```

## Prefetching on Hover
```tsx
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)
```tsx
// 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 `queryOptions` outside of components — not inline in `useQuery()`
- Never use `useEffect` to fetch data — use loaders or `useQuery`
- 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
Loading
Loading