1258 lines
33 KiB
Plaintext
1258 lines
33 KiB
Plaintext
# 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
|