Files
gh-madappgang-claude-code-p…/skills/best-practices.md.archive
2025-11-30 08:38:57 +08:00

1258 lines
33 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Best Practices for Development
This skill provides production-ready best practices for building SPA React applications. Use this guidance when implementing features, reviewing code, or making architectural decisions.
## Stack Overview
- **React 19** with React Compiler (auto-memoization)
- **TypeScript** (strict mode)
- **Vite** (bundler)
- **Biome** (formatting + linting)
- **TanStack Query** (server state)
- **TanStack Router** (file-based routing)
- **Vitest** (testing with jsdom)
- **Apidog MCP** (API spec source of truth)
## Project Structure
```
/src
/app/ # App shell, providers, global styles
/routes/ # TanStack Router file-based routes
/components/ # Reusable, pure UI components (no data-fetch)
/features/ # Feature folders (UI + hooks local to a feature)
/api/ # Generated API types & client (from OpenAPI)
/lib/ # Utilities (zod schemas, date, formatting, etc.)
/test/ # Test utilities
```
**Key Principles:**
- One responsibility per file
- UI components don't fetch server data
- Put queries/mutations in feature hooks
- Co-locate tests next to files
## Tooling Configuration
### 1. Vite + React 19 + React Compiler
```typescript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react({
babel: {
// React Compiler must run first:
plugins: ['babel-plugin-react-compiler'],
},
}),
],
})
```
**Verify:** Check DevTools for "Memo ✨" badge on optimized components.
### 2. TypeScript (strict + bundler mode)
```json
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "vitest"]
},
"include": ["src", "vitest-setup.ts"]
}
```
### 3. Biome (formatter + linter)
```bash
npx @biomejs/biome init
npx @biomejs/biome check --write .
```
```json
// biome.json
{
"formatter": { "enabled": true, "lineWidth": 100 },
"linter": {
"enabled": true,
"rules": {
"style": { "noUnusedVariables": "error" }
}
}
}
```
### 4. Environment Variables
- Read via `import.meta.env`
- Prefix all app-exposed vars with `VITE_`
- Never place secrets in the client bundle
## Testing Setup (Vitest)
```typescript
// vitest-setup.ts
import '@testing-library/jest-dom/vitest'
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest-setup.ts'],
coverage: { reporter: ['text', 'html'] }
}
})
```
- Use React Testing Library for DOM assertions
- Use msw for API mocks
- Add `types: ["vitest", "vitest/jsdom"]` for jsdom globals
## React 19 Guidelines
### Compiler-Friendly Code
- Keep components pure and props serializable
- Derive values during render (don't stash in refs unnecessarily)
- Keep event handlers inline unless they close over large mutable objects
- Verify compiler is working (DevTools ✨)
- Opt-out problematic components with `"use no memo"` while refactoring
### Actions & Forms
For SPA mutations, choose one per feature:
- **React 19 Actions:** `<form action={fn}>`, `useActionState`, `useOptimistic`
- **TanStack Query:** `useMutation`
Don't duplicate logic between both approaches.
### `use` Hook
- Primarily useful with Suspense/data primitives and RSC
- For SPA-only apps, prefer Query + Router loaders
## Routing (TanStack Router)
### Installation
```bash
pnpm add @tanstack/react-router
pnpm add -D @tanstack/router-plugin
```
```typescript
// vite.config.ts
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [react(), TanStackRouterVite()],
})
```
### Bootstrap
```typescript
// src/main.tsx
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode><RouterProvider router={router} /></StrictMode>
)
```
### File-Based Routes
```
src/routes/__root.tsx // layout (Outlet, providers)
src/routes/index.tsx // "/"
src/routes/users/index.tsx // "/users"
src/routes/users/$id.tsx // "/users/:id"
```
- Supports typed search params (JSON-first)
- Validate at route boundaries
## Server State (TanStack Query v5)
**TanStack Query v5** (October 2023) is the async state manager for this project. It requires React 18+, features first-class Suspense support, improved TypeScript inference, and a 20% smaller bundle. This section covers production-ready patterns based on official documentation and community best practices.
### Breaking Changes in v5
**Key updates you need to know:**
1. **Single Object Signature**: All hooks now accept one configuration object:
```typescript
// ✅ v5 - single object
useQuery({ queryKey, queryFn, ...options })
// ❌ v4 - multiple overloads (deprecated)
useQuery(queryKey, queryFn, options)
```
2. **Renamed Options**:
- `cacheTime` → `gcTime` (garbage collection time)
- `keepPreviousData` → `placeholderData: keepPreviousData`
- `isLoading` now means `isPending && isFetching`
3. **Callbacks Removed from useQuery**:
- `onSuccess`, `onError`, `onSettled` removed from `useQuery`
- Use global QueryCache callbacks instead
- Prevents duplicate executions
4. **Infinite Queries Require initialPageParam**:
- No default value provided
- Must explicitly set `initialPageParam` (e.g., `0` or `null`)
5. **First-Class Suspense**:
- New dedicated hooks: `useSuspenseQuery`, `useSuspenseInfiniteQuery`
- No experimental flag needed
- Data is never undefined at type level
**Migration**: Use the official codemod for automatic migration: `npx @tanstack/query-codemods v5/replace-import-specifier`
### Smart Defaults
Query v5 ships with production-ready defaults:
```typescript
{
staleTime: 0, // Data instantly stale (refetch on mount)
gcTime: 5 * 60_000, // Keep unused cache for 5 minutes
retry: 3, // 3 retries with exponential backoff
refetchOnWindowFocus: true,// Refetch when user returns to tab
refetchOnReconnect: true, // Refetch when network reconnects
}
```
**Philosophy**: React Query is an **async state manager, not a data fetcher**. You provide the Promise; Query manages caching, background updates, and synchronization.
### Client Setup
```typescript
// src/app/providers.tsx
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
import { toast } from './toast' // Your notification system
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0, // Adjust per-query
gcTime: 5 * 60_000, // 5 minutes (v5: formerly cacheTime)
retry: (failureCount, error) => {
// Don't retry on 401 (authentication errors)
if (error?.response?.status === 401) return false
return failureCount < 3
},
},
},
queryCache: new QueryCache({
onError: (error, query) => {
// Only show toast for background errors (when data exists)
if (query.state.data !== undefined) {
toast.error(`Something went wrong: ${error.message}`)
}
},
}),
})
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
```
**DevTools Setup** (auto-excluded in production):
```typescript
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
```
### Architecture: Feature-Based Colocation
**Recommended pattern**: Group queries with related features, not by file type.
```
src/features/
├── Todos/
│ ├── index.tsx # Feature entry point
│ ├── queries.ts # All React Query logic (keys, functions, hooks)
│ ├── types.ts # TypeScript types
│ └── components/ # Feature-specific components
```
**Export only custom hooks** from query files. Keep query functions and keys private:
```typescript
// features/todos/queries.ts
// 1. Query Key Factory (hierarchical structure)
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
}
// 2. Query Function (private)
const fetchTodos = async (filters: string): Promise<Todo[]> => {
const response = await axios.get('/api/todos', { params: { filters } })
return response.data
}
// 3. Custom Hook (public API)
export const useTodosQuery = (filters: string) => {
return useQuery({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodos(filters),
staleTime: 30_000, // Fresh for 30 seconds
})
}
```
**Benefits**:
- Prevents key/function mismatches
- Clean public API
- Encapsulation and maintainability
- Easy to locate all query logic for a feature
### Query Key Factories (Essential)
**Structure keys hierarchically** from generic to specific:
```typescript
// ✅ Correct hierarchy
['todos'] // Invalidates everything
['todos', 'list'] // Invalidates all lists
['todos', 'list', { filters }] // Invalidates specific list
['todos', 'detail', 1] // Invalidates specific detail
// ❌ Wrong - flat structure
['todos-list-active'] // Can't partially invalidate
```
**Critical rule**: Query keys must include **ALL variables used in queryFn**. Treat query keys like dependency arrays:
```typescript
// ✅ Correct - includes all variables
const { data } = useQuery({
queryKey: ['todos', filters, sortBy],
queryFn: () => fetchTodos(filters, sortBy),
})
// ❌ Wrong - missing variables
const { data } = useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos(filters, sortBy), // filters/sortBy not in key!
})
```
**Type consistency matters**: `['todos', '1']` and `['todos', 1]` are **different keys**. Be consistent with types.
### Query Options API (Type Safety)
**The modern pattern** for maximum type safety across your codebase:
```typescript
import { queryOptions } from '@tanstack/react-query'
function todoOptions(id: number) {
return queryOptions({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
staleTime: 5000,
})
}
// ✅ Use everywhere with full type safety
useQuery(todoOptions(1))
queryClient.prefetchQuery(todoOptions(5))
queryClient.setQueryData(todoOptions(42).queryKey, newTodo)
queryClient.getQueryData(todoOptions(42).queryKey) // Fully typed!
```
**Benefits**:
- Single source of truth for query configuration
- Full TypeScript inference for imperatively accessed data
- Reusable across hooks and imperative methods
- Prevents key/function mismatches
### Data Transformation Strategies
Choose the right approach based on your use case:
**1. Transform in queryFn** - Simple cases where cache should store transformed data:
```typescript
const fetchTodos = async (): Promise<Todo[]> => {
const response = await axios.get('/api/todos')
return response.data.map(todo => ({
...todo,
name: todo.name.toUpperCase()
}))
}
```
**2. Transform with `select` option (RECOMMENDED)** - Enables partial subscriptions:
```typescript
// Only re-renders when filtered data changes
export const useTodosQuery = (filters: string) =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.filter(todo => todo.status === filters),
})
// Only re-renders when count changes
export const useTodosCount = () =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.length,
})
```
**⚠️ Memoize select functions** to prevent running on every render:
```typescript
// ✅ Stable reference
const transformTodos = (data: Todo[]) => expensiveTransform(data)
const query = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: transformTodos, // Stable function reference
})
// ❌ Runs on every render
const query = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => expensiveTransform(data), // New function every render
})
```
### TypeScript Best Practices
**Let TypeScript infer types** from queryFn rather than specifying generics:
```typescript
// ✅ Recommended - inference
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos, // Returns Promise<Todo[]>
})
// data is Todo[] | undefined
// ❌ Unnecessary - explicit generics
const { data } = useQuery<Todo[]>({
queryKey: ['todos'],
queryFn: fetchTodos,
})
```
**Discriminated unions** automatically narrow types:
```typescript
const { data, isSuccess, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isSuccess) {
// data is Todo[] (never undefined)
}
if (isError) {
// error is defined
}
```
Use `queryOptions` helper for maximum type safety across imperative methods.
### Custom Hooks Pattern
**Always create custom hooks** even for single queries:
```typescript
// ✅ Recommended - custom hook with encapsulation
export function usePost(
id: number,
options?: Omit<UseQueryOptions<Post>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: ['posts', id],
queryFn: () => getPost(id),
...options,
})
}
// Usage: allows callers to override any option except key/fn
const { data } = usePost(42, { staleTime: 10_000 })
```
**Benefits**:
- Centralizes query logic
- Easy to update all usages
- Consistent configuration
- Better testing
### Error Handling (Multi-Layer Strategy)
**Layer 1: Component-Level** - Specific user feedback:
```typescript
function TodoList() {
const { data, error, isError, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isLoading) return <Spinner />
if (isError) return <ErrorAlert>{error.message}</ErrorAlert>
return <ul>{data.map(todo => <TodoItem key={todo.id} {...todo} />)}</ul>
}
```
**Layer 2: Global Error Handling** - Background errors via QueryCache:
```typescript
// Already configured in client setup above
queryCache: new QueryCache({
onError: (error, query) => {
if (query.state.data !== undefined) {
toast.error(`Background error: ${error.message}`)
}
},
})
```
**Layer 3: Error Boundaries** - Catch render errors:
```typescript
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<TodoList />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
```
### Suspense Integration
**First-class Suspense support** in v5 with dedicated hooks:
```typescript
import { useSuspenseQuery } from '@tanstack/react-query'
function TodoList() {
// data is NEVER undefined (type-safe)
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return <ul>{data.map(todo => <TodoItem key={todo.id} {...todo} />)}</ul>
}
// Wrap with Suspense boundary
function App() {
return (
<Suspense fallback={<Spinner />}>
<TodoList />
</Suspense>
)
}
```
**Benefits**:
- Eliminates loading state management
- Data always defined (TypeScript enforced)
- Cleaner component code
- Works with React.lazy for code-splitting
### Mutations with Optimistic Updates
**Basic mutation** with cache invalidation:
```typescript
export function useCreateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newTodo: CreateTodoDTO) =>
api.post('/todos', newTodo).then(res => res.data),
onSuccess: (data) => {
// Set detail query immediately
queryClient.setQueryData(['todos', data.id], data)
// Invalidate list queries
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
},
})
}
```
**Simple optimistic updates** using `variables`:
```typescript
const addTodoMutation = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/todos', { text: newTodo }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
const { isPending, variables, mutate } = addTodoMutation
return (
<ul>
{todoQuery.data?.map(todo => <li key={todo.id}>{todo.text}</li>)}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
</ul>
)
```
**Advanced optimistic updates** with rollback:
```typescript
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing queries (prevent race conditions)
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot current data
const previousTodos = queryClient.getQueryData(['todos'])
// Optimistically update cache
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old?.map(todo => todo.id === newTodo.id ? newTodo : todo)
)
// Return context for rollback
return { previousTodos }
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previousTodos)
toast.error('Update failed. Changes reverted.')
},
onSettled: () => {
// Always refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
```
**Key principles**:
- Cancel ongoing queries in `onMutate` to prevent race conditions
- Snapshot previous data before updating
- Restore snapshot on error
- Always invalidate in `onSettled` for eventual consistency
- **Never mutate cached data directly** - always use immutable updates
### Authentication Integration
**Handle token refresh at HTTP client level** (not React Query):
```typescript
// src/lib/api-client.ts
import axios from 'axios'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
})
// Add token to requests
apiClient.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// Refresh token on 401
const refreshAuth = async (failedRequest: any) => {
try {
const newToken = await fetchNewToken()
failedRequest.response.config.headers.Authorization = `Bearer ${newToken}`
setAccessToken(newToken)
return Promise.resolve()
} catch {
removeAccessToken()
window.location.href = '/login'
return Promise.reject()
}
}
createAuthRefreshInterceptor(apiClient, refreshAuth, {
statusCodes: [401],
pauseInstanceWhileRefreshing: true,
})
```
**Protected queries** use the `enabled` option:
```typescript
const useTodos = () => {
const { user } = useUser() // Get current user from auth context
return useQuery({
queryKey: ['todos', user?.id],
queryFn: () => fetchTodos(user.id),
enabled: !!user, // Only execute when user exists
})
}
```
**On logout**: Clear the entire cache with `queryClient.clear()` (not `invalidateQueries()` which triggers refetches):
```typescript
const logout = () => {
removeAccessToken()
queryClient.clear() // Clear all cached data
navigate('/login')
}
```
### Advanced Patterns
**Prefetching** - Eliminate loading states:
```typescript
// Hover prefetching
function ShowDetailsButton() {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['details'],
queryFn: getDetailsData,
staleTime: 60_000, // Consider fresh for 1 minute
})
}
return (
<button onMouseEnter={prefetch} onClick={showDetails}>
Show Details
</button>
)
}
// Route-level prefetching (see Router × Query Integration section)
```
**Infinite Queries** - Infinite scrolling/pagination:
```typescript
function Projects() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // Required in v5
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
if (isLoading) return <Spinner />
return (
<>
{data.pages.map((page, i) => (
<React.Fragment key={i}>
{page.data.map(project => (
<ProjectCard key={project.id} {...project} />
))}
</React.Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</>
)
}
```
**Offset-Based Pagination** with `placeholderData`:
```typescript
import { keepPreviousData } from '@tanstack/react-query'
function Posts() {
const [page, setPage] = useState(0)
const { data, isPending, isPlaceholderData } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
placeholderData: keepPreviousData, // Show previous data while fetching
})
return (
<>
{data.posts.map(post => <PostCard key={post.id} {...post} />)}
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data.hasMore}
>
Next
</button>
</>
)
}
```
**Dependent Queries** - Sequential data fetching:
```typescript
function UserProjects({ email }: { email: string }) {
// First query
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => getUserByEmail(email),
})
// Second query waits for first
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => getProjectsByUser(user.id),
enabled: !!user?.id, // Only runs when user.id exists
})
return <div>{/* render projects */}</div>
}
```
### Performance Optimization
**staleTime is your primary control** - adjust this, not `gcTime`:
```typescript
// Real-time data (default)
staleTime: 0 // Always considered stale, refetch on mount
// User profiles (changes infrequently)
staleTime: 1000 * 60 * 2 // Fresh for 2 minutes
// Static reference data
staleTime: 1000 * 60 * 10 // Fresh for 10 minutes
```
**Query deduplication** happens automatically - multiple components mounting with identical query keys result in a single network request, but all components receive data.
**Prevent request waterfalls**:
```typescript
// ❌ Waterfall - each query waits for previous
function Dashboard() {
const { data: user } = useQuery(userQuery)
const { data: posts } = useQuery(postsQuery(user?.id))
const { data: stats } = useQuery(statsQuery(user?.id))
}
// ✅ Parallel - all queries start simultaneously
function Dashboard() {
const { data: user } = useQuery(userQuery)
const { data: posts } = useQuery({
...postsQuery(user?.id),
enabled: !!user?.id,
})
const { data: stats } = useQuery({
...statsQuery(user?.id),
enabled: !!user?.id,
})
}
// ✅ Best - prefetch in route loader (see Router × Query Integration)
```
**Never copy server state to local state** - this opts out of background updates:
```typescript
// ❌ Wrong - copies to state, loses reactivity
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
const [todos, setTodos] = useState(data)
// ✅ Correct - use query data directly
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
```
### Testing with Mock Service Worker (MSW)
**MSW is the recommended approach** - mock the network layer:
```typescript
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: 1, text: 'Test todo', completed: false },
])
}),
http.post('/api/todos', async ({ request }) => {
const newTodo = await request.json()
return HttpResponse.json({ id: 2, ...newTodo })
}),
]
// src/test/setup.ts
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'
export const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
```
**Create test wrappers** with proper QueryClient:
```typescript
// src/test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Prevent retries in tests
gcTime: Infinity,
},
},
})
}
export function renderWithClient(ui: React.ReactElement) {
const testQueryClient = createTestQueryClient()
return render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
)
}
```
**Test queries**:
```typescript
import { renderWithClient } from '@/test/utils'
import { screen } from '@testing-library/react'
test('displays todos', async () => {
renderWithClient(<TodoList />)
// Wait for data to load
expect(await screen.findByText('Test todo')).toBeInTheDocument()
})
test('shows error state', async () => {
// Override handler for this test
server.use(
http.get('/api/todos', () => {
return HttpResponse.json(
{ message: 'Failed to fetch' },
{ status: 500 }
)
})
)
renderWithClient(<TodoList />)
expect(await screen.findByText(/failed/i)).toBeInTheDocument()
})
```
**Critical testing principles**:
- Create new QueryClient per test for isolation
- Set `retry: false` to prevent timeouts
- Use async queries (`findBy*`) for data that loads
- Silence console.error for expected errors
### Anti-Patterns to Avoid
**❌ Don't store query data in Redux/Context**:
- Creates dual sources of truth
- Loses automatic cache invalidation
- Triggers unnecessary renders
**❌ Don't call refetch() with different parameters**:
```typescript
// ❌ Wrong - breaks declarative pattern
const { data, refetch } = useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos(filters),
})
// Later: refetch with different filters??? Won't work!
// ✅ Correct - include params in key
const [filters, setFilters] = useState('all')
const { data } = useQuery({
queryKey: ['todos', filters],
queryFn: () => fetchTodos(filters),
})
// Changing filters automatically refetches
```
**❌ Don't use queries for local state**:
- Query Cache expects refetchable data
- Use useState/useReducer for client-only state
**❌ Don't create QueryClient inside components**:
```typescript
// ❌ Wrong - new cache every render
function App() {
const client = new QueryClient()
return <QueryClientProvider client={client}>...</QueryClientProvider>
}
// ✅ Correct - stable instance
const queryClient = new QueryClient()
function App() {
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}
```
**❌ Don't ignore loading and error states** - always handle both
**❌ Don't transform data by copying to state** - use `select` option
**❌ Don't mismatch query keys** - be consistent with types (`'1'` vs `1`)
### Cache Timing Guidelines
**staleTime** - How long data is considered fresh:
- `0` (default) - Always stale, refetch on mount/focus
- `30_000` (30s) - Good for user-generated content
- `120_000` (2min) - Good for profile data
- `600_000` (10min) - Good for static reference data
**gcTime** (formerly cacheTime) - How long unused data stays in cache:
- `300_000` (5min, default) - Good for most cases
- `Infinity` - Keep forever (useful with persistence)
- `0` - Immediate garbage collection (not recommended)
**Relationship**: `staleTime` controls refetch frequency, `gcTime` controls memory cleanup.
## Router × Query Integration
### Route Loader + Query Prefetch
```typescript
// src/routes/users/$id.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryClient } from '@/app/queryClient'
import { usersKeys, fetchUser } from '@/features/users/queries'
export const Route = createFileRoute('/users/$id')({
loader: async ({ params }) => {
const id = params.id
return queryClient.ensureQueryData({
queryKey: usersKeys.detail(id),
queryFn: () => fetchUser(id),
staleTime: 30_000,
})
},
component: UserPage,
})
```
**Benefits:**
- Loaders run before render, eliminating waterfall
- Fast SPA navigations
- Add Router & Query DevTools during development (auto-hide in production)
## API Integration (Apidog + MCP)
### Goal
The AI agent always uses the latest API.
### Process
1. **Expose OpenAPI from Apidog** (3.0/3.1 export or URL)
2. **Wire MCP:**
```json
// mcp.json
{
"mcpServers": {
"API specification": {
"command": "npx",
"args": ["-y", "apidog-mcp-server@latest", "--oas=https://your.domain/openapi.json"]
}
}
}
```
- Supports remote URL or local file
- Multiple specs via multiple MCP entries
3. **Generate Types & Client** in `/src/api`:
- Lightweight: hand-rolled fetch with zod parsing
- Codegen: OpenAPI TS generator + import client
4. **Query Layer:**
- Build `queryOptions`/mutation wrappers
- Accept typed params, return parsed data
- Keep all HTTP details under `/api`
## Performance, Accessibility, Security
### Performance
- **Code-splitting:** TanStack Router file-based routing + Vite `dynamic import()`
- **React Compiler first:** Keep components pure for auto-memoization
- **Images & assets:** Use Vite asset pipeline; prefer modern formats
### Accessibility
- Use semantic elements
- Test with RTL queries (by role/label)
### Security
- Never ship secrets
- Only `VITE_*` envs are exposed
- Validate all untrusted data at boundaries (server → zod parse)
- Pin/renovate deps; avoid known compromised packages
- Run CI with `--ignore-scripts` when possible
## Agent Execution Rules
**Always do this when you add or modify code:**
1. **API Spec:** Fetch latest via Apidog MCP and regenerate `/src/api` types if changed
2. **Data Access:** Wire only through feature hooks that wrap TanStack Query. Never fetch inside UI components.
3. **New Routes:**
- Create file under `/src/routes/**` (file-based routing)
- If needs data at navigation, add loader that prefetches with Query
4. **Server Mutations:**
- Use React 19 Actions OR TanStack Query `useMutation` (choose one per feature)
- Use optimistic UI via `useOptimistic` (Actions) or Query's optimistic updates
- Invalidate/selectively update cache on success
5. **Compiler-Friendly:**
- Keep code pure (pure components, minimal effects)
- If compiler flags something, fix it or add `"use no memo"` temporarily
6. **Tests:**
- Add Vitest tests for new logic
- Component tests use RTL
- Stub network with msw
7. **Before Committing:**
- Run `biome check --write`
- Ensure Vite build passes
## "Done" Checklist per PR
- [ ] Route file added/updated; loader prefetch (if needed) present
- [ ] Query keys are stable (`as const`), `staleTime`/`gcTime` tuned
- [ ] Component remains pure; no unnecessary effects; compiler ✨ visible
- [ ] API calls typed from `/src/api`; inputs/outputs validated at boundaries
- [ ] Tests cover new logic; Vitest jsdom setup passes
- [ ] `biome check --write` clean; Vite build ok
## Authoritative Sources
- **React 19 & Compiler:**
- React v19 overview
- React Compiler: overview + installation + verification
- `<form action>` / Actions API; `useOptimistic`; `use`
- CRA deprecation & guidance
- **Vite:**
- Getting started; env & modes; TypeScript targets
- **TypeScript:**
- `moduleResolution: "bundler"` (for bundlers like Vite)
- **Biome:**
- Formatter/Linter configuration & CLI usage
- **TanStack Query:**
- Caching & important defaults; v5 migration notes; devtools/persisting cache
- **TanStack Router:**
- Install with Vite plugin; file-based routing; search params; devtools
- **Vitest:**
- Getting started & config (jsdom)
- **Apidog + MCP:**
- Apidog docs (import/export, OpenAPI); MCP server usage
## Final Notes
- Favor compile-friendly React patterns
- Let the compiler and Query/Router handle perf and data orchestration
- Treat Apidog's OpenAPI (via MCP) as the single source of truth for network shapes
- Keep this doc as your "contract"—don't add heavy frameworks or configs beyond what's here unless explicitly requested