Build, Test, and Push CFDM Docker Image / test (push) Failing after 1m51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
274 lines
7.9 KiB
Plaintext
274 lines
7.9 KiB
Plaintext
---
|
|
description: "Cursor rules for React SPAs combining TanStack Router v1 and TanStack Query v5 for zero-loading-spinner routing and type-safe server state."
|
|
globs: **/*
|
|
alwaysApply: false
|
|
---
|
|
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 }) => {
|
|
if (!context.auth.isAuthenticated) {
|
|
throw redirect({ to: '/login', search: { redirect: location.pathname } })
|
|
}
|
|
},
|
|
})
|
|
```
|
|
|
|
## 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
|