Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:25:35 +08:00
commit 4c2042cce6
23 changed files with 5180 additions and 0 deletions

View File

@@ -0,0 +1,304 @@
# TanStack Query Best Practices
**Performance, caching strategies, and common patterns**
---
## 1. Avoid Request Waterfalls
### ❌ Bad: Sequential Dependencies
```tsx
function BadUserProfile({ userId }) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
})
// Waits for user ⏳
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user!.id),
enabled: !!user,
})
// Waits for posts ⏳⏳
const { data: comments } = useQuery({
queryKey: ['comments', posts?.[0]?.id],
queryFn: () => fetchComments(posts![0].id),
enabled: !!posts && posts.length > 0,
})
}
```
### ✅ Good: Parallel Queries
```tsx
function GoodUserProfile({ userId }) {
// All run in parallel 🚀
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
})
const { data: posts } = useQuery({
queryKey: ['posts', userId], // Use userId, not user.id
queryFn: () => fetchPosts(userId),
})
const { data: comments } = useQuery({
queryKey: ['comments', userId],
queryFn: () => fetchUserComments(userId),
})
}
```
---
## 2. Query Key Strategy
### Hierarchical Structure
```tsx
// Global
['todos'] // All todos
['todos', { status: 'done' }] // Filtered todos
['todos', 123] // Single todo
// Invalidation hierarchy
queryClient.invalidateQueries({ queryKey: ['todos'] }) // Invalidates ALL todos
queryClient.invalidateQueries({ queryKey: ['todos', { status: 'done' }] }) // Only filtered
```
### Best Practices
```tsx
// ✅ Good: Stable, serializable keys
['users', userId, { sort: 'name', filter: 'active' }]
// ❌ Bad: Functions in keys (not serializable)
['users', () => userId]
// ❌ Bad: Changing order
['users', { filter: 'active', sort: 'name' }] // Different key!
// ✅ Good: Consistent ordering
const userFilters = { filter: 'active', sort: 'name' }
```
---
## 3. Caching Configuration
### staleTime vs gcTime
```tsx
/**
* staleTime: How long data is "fresh" (won't refetch)
* gcTime: How long unused data stays in cache
*/
// Real-time data
staleTime: 0 // Always stale, refetch frequently
gcTime: 1000 * 60 * 5 // 5 min in cache
// Stable data
staleTime: 1000 * 60 * 60 // 1 hour fresh
gcTime: 1000 * 60 * 60 * 24 // 24 hours in cache
// Static data
staleTime: Infinity // Never stale
gcTime: Infinity // Never garbage collect
```
### Per-Query vs Global
```tsx
// Global defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 60,
},
},
})
// Override per query
useQuery({
queryKey: ['stock-price'],
queryFn: fetchStockPrice,
staleTime: 0, // Override: always stale
refetchInterval: 1000 * 30, // Refetch every 30s
})
```
---
## 4. Use queryOptions Factory
```tsx
// ✅ Best practice: Reusable options
export const todosQueryOptions = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60,
})
// Use everywhere
useQuery(todosQueryOptions)
useSuspenseQuery(todosQueryOptions)
queryClient.prefetchQuery(todosQueryOptions)
// ❌ Bad: Duplicated configuration
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useSuspenseQuery({ queryKey: ['todos'], queryFn: fetchTodos })
```
---
## 5. Data Transformations
### select Option
```tsx
// Only re-render when count changes
function TodoCount() {
const { data: count } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.length, // Transform
})
}
// Cache full data, component gets filtered
function CompletedTodos() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.filter(todo => todo.completed),
})
}
```
---
## 6. Prefetching
```tsx
function TodoList() {
const queryClient = useQueryClient()
const { data: todos } = useTodos()
const prefetch = (id: number) => {
queryClient.prefetchQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
staleTime: 1000 * 60 * 5,
})
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id} onMouseEnter={() => prefetch(todo.id)}>
<Link to={`/todos/${todo.id}`}>{todo.title}</Link>
</li>
))}
</ul>
)
}
```
---
## 7. Optimistic Updates
Use for:
- ✅ Low-risk actions (toggle, like)
- ✅ Frequent actions (better UX)
Avoid for:
- ❌ Critical operations (payments)
- ❌ Complex validations
```tsx
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
return { previous }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
```
---
## 8. Error Handling Strategy
### Local vs Global
```tsx
// Local: Handle in component
const { data, error, isError } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isError) return <div>Error: {error.message}</div>
// Global: Error boundaries
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: true, // Throw to boundary
})
// Conditional: Mix both
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: (error) => error.status >= 500, // Only 5xx to boundary
})
```
---
## 9. Server State vs Client State
```tsx
// ❌ Don't use TanStack Query for client state
const { data: isModalOpen } = useMutation(...)
// ✅ Use useState for client state
const [isModalOpen, setIsModalOpen] = useState(false)
// ✅ Use TanStack Query for server state only
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
```
---
## 10. Performance Monitoring
### Use DevTools
- Check refetch frequency
- Verify cache hits
- Monitor query states
- Export state for debugging
### Key Metrics
- Time to first data
- Cache hit rate
- Refetch frequency
- Network requests count

View File

@@ -0,0 +1,271 @@
# Common TanStack Query Patterns
**Reusable patterns for real-world applications**
---
## Pattern 1: Dependent Queries
Query B depends on data from Query A:
```tsx
function UserPosts({ userId }) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
})
const { data: posts } = useQuery({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
enabled: !!user, // Wait for user
})
}
```
---
## Pattern 2: Parallel Queries with useQueries
Fetch multiple resources in parallel:
```tsx
function TodoDetails({ ids }) {
const results = useQueries({
queries: ids.map(id => ({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
})),
})
const isLoading = results.some(r => r.isPending)
const data = results.map(r => r.data)
}
```
---
## Pattern 3: Paginated Queries with placeholderData
Keep previous data while fetching next page:
```tsx
import { keepPreviousData } from '@tanstack/react-query'
function PaginatedTodos() {
const [page, setPage] = useState(0)
const { data } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData, // Keep old data while loading
})
}
```
---
## Pattern 4: Infinite Scroll
Auto-load more data on scroll:
```tsx
function InfiniteList() {
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam }) => fetchItems(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// Intersection Observer for auto-loading
const ref = useRef()
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => entry.isIntersecting && hasNextPage && fetchNextPage()
)
if (ref.current) observer.observe(ref.current)
return () => observer.disconnect()
}, [fetchNextPage, hasNextPage])
return (
<>
{data.pages.map(page => page.data.map(item => <div>{item}</div>))}
<div ref={ref}>Loading...</div>
</>
)
}
```
---
## Pattern 5: Optimistic Updates
Instant UI feedback:
```tsx
function useOptimisticToggle() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateTodo,
onMutate: async (updated) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) =>
old.map(todo => todo.id === updated.id ? updated : todo)
)
return { previous }
},
onError: (err, vars, context) => {
queryClient.setQueryData(['todos'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
```
---
## Pattern 6: Prefetching on Hover
Load data before user clicks:
```tsx
function TodoList() {
const queryClient = useQueryClient()
const prefetch = (id) => {
queryClient.prefetchQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
})
}
return (
<ul>
{todos.map(todo => (
<li onMouseEnter={() => prefetch(todo.id)}>
<Link to={`/todos/${todo.id}`}>{todo.title}</Link>
</li>
))}
</ul>
)
}
```
---
## Pattern 7: Search/Debounce
Debounced search with automatic cancellation:
```tsx
import { useState, useDeferredValue } from 'react'
function Search() {
const [search, setSearch] = useState('')
const deferredSearch = useDeferredValue(search)
const { data } = useQuery({
queryKey: ['search', deferredSearch],
queryFn: ({ signal }) =>
fetch(`/api/search?q=${deferredSearch}`, { signal }).then(r => r.json()),
enabled: deferredSearch.length >= 2,
})
}
```
---
## Pattern 8: Polling/Refetch Interval
Auto-refetch every N seconds:
```tsx
const { data } = useQuery({
queryKey: ['stock-price'],
queryFn: fetchStockPrice,
refetchInterval: 1000 * 30, // Every 30 seconds
refetchIntervalInBackground: true, // Even when tab inactive
})
```
---
## Pattern 9: Conditional Fetching
Only fetch when needed:
```tsx
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId && isAuthenticated,
})
```
---
## Pattern 10: Initial Data from Cache
Use cached data as initial value:
```tsx
const { data: todo } = useQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
initialData: () => {
return queryClient
.getQueryData(['todos'])
?.find(t => t.id === id)
},
})
```
---
## Pattern 11: Mutation with Multiple Invalidations
Update multiple related queries:
```tsx
useMutation({
mutationFn: updateTodo,
onSuccess: (updated) => {
queryClient.setQueryData(['todos', updated.id], updated)
queryClient.invalidateQueries({ queryKey: ['todos'] })
queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ['users', updated.userId] })
},
})
```
---
## Pattern 12: Global Error Handler
Centralized error handling:
```tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError: (error) => {
toast.error(error.message)
logToSentry(error)
},
},
mutations: {
onError: (error) => {
toast.error('Action failed')
logToSentry(error)
},
},
},
})
```

View File

@@ -0,0 +1,26 @@
# [TODO: Reference Document Name]
[TODO: This file contains reference documentation that Claude can load when needed.]
[TODO: Delete this file if you don't have reference documentation to provide.]
## Purpose
[TODO: Explain what information this document contains]
## When Claude Should Use This
[TODO: Describe specific scenarios where Claude should load this reference]
## Content
[TODO: Add your reference content here - schemas, guides, specifications, etc.]
---
**Note**: This file is NOT loaded into context by default. Claude will only load it when:
- It determines the information is needed
- You explicitly ask Claude to reference it
- The SKILL.md instructions direct Claude to read it
Keep this file under 10k words for best performance.

282
references/testing.md Normal file
View File

@@ -0,0 +1,282 @@
# Testing TanStack Query
**Testing queries, mutations, and components**
---
## Setup
```bash
npm install -D @testing-library/react @testing-library/jest-dom vitest msw
```
### Test Utils
```tsx
// 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, // Disable retries in tests
gcTime: Infinity,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {}, // Silence errors in tests
},
})
}
export function renderWithClient(ui: React.ReactElement) {
const testQueryClient = createTestQueryClient()
return render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
)
}
```
---
## Testing Queries
```tsx
import { renderHook, waitFor } from '@testing-library/react'
import { useTodos } from './useTodos'
describe('useTodos', () => {
it('fetches todos successfully', async () => {
const { result } = renderHook(() => useTodos(), {
wrapper: ({ children }) => (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
),
})
// Initially pending
expect(result.current.isPending).toBe(true)
// Wait for success
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Check data
expect(result.current.data).toHaveLength(3)
})
it('handles errors', async () => {
// Mock fetch to fail
global.fetch = vi.fn(() =>
Promise.reject(new Error('API error'))
)
const { result } = renderHook(() => useTodos())
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error?.message).toBe('API error')
})
})
```
---
## Testing with MSW
```tsx
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: 1, title: 'Test todo', completed: false },
])
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('fetches todos', async () => {
const { result } = renderHook(() => useTodos())
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual([
{ id: 1, title: 'Test todo', completed: false },
])
})
test('handles server error', async () => {
server.use(
http.get('/api/todos', () => {
return new HttpResponse(null, { status: 500 })
})
)
const { result } = renderHook(() => useTodos())
await waitFor(() => expect(result.current.isError).toBe(true))
})
```
---
## Testing Mutations
```tsx
test('adds todo successfully', async () => {
const { result } = renderHook(() => useAddTodo())
act(() => {
result.current.mutate({ title: 'New todo' })
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(
expect.objectContaining({ title: 'New todo' })
)
})
test('handles mutation error', async () => {
server.use(
http.post('/api/todos', () => {
return new HttpResponse(null, { status: 400 })
})
)
const { result } = renderHook(() => useAddTodo())
act(() => {
result.current.mutate({ title: 'New todo' })
})
await waitFor(() => expect(result.current.isError).toBe(true))
})
```
---
## Testing Components
```tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoList } from './TodoList'
test('displays todos', async () => {
renderWithClient(<TodoList />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Test todo')).toBeInTheDocument()
})
})
test('adds new todo', async () => {
renderWithClient(<TodoList />)
await waitFor(() => {
expect(screen.getByText('Test todo')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText(/new todo/i)
const button = screen.getByRole('button', { name: /add/i })
await userEvent.type(input, 'Another todo')
await userEvent.click(button)
await waitFor(() => {
expect(screen.getByText('Another todo')).toBeInTheDocument()
})
})
```
---
## Testing with Prefilled Cache
```tsx
test('uses prefilled cache', () => {
const queryClient = createTestQueryClient()
// Prefill cache
queryClient.setQueryData(['todos'], [
{ id: 1, title: 'Cached todo', completed: false },
])
render(
<QueryClientProvider client={queryClient}>
<TodoList />
</QueryClientProvider>
)
// Should immediately show cached data
expect(screen.getByText('Cached todo')).toBeInTheDocument()
})
```
---
## Testing Optimistic Updates
```tsx
test('optimistic update rollback on error', async () => {
const queryClient = createTestQueryClient()
queryClient.setQueryData(['todos'], [
{ id: 1, title: 'Original', completed: false },
])
server.use(
http.patch('/api/todos/1', () => {
return new HttpResponse(null, { status: 500 })
})
)
const { result } = renderHook(() => useUpdateTodo(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
})
act(() => {
result.current.mutate({ id: 1, completed: true })
})
// Check optimistic update
expect(queryClient.getQueryData(['todos'])).toEqual([
{ id: 1, title: 'Original', completed: true },
])
// Wait for rollback
await waitFor(() => expect(result.current.isError).toBe(true))
// Should rollback
expect(queryClient.getQueryData(['todos'])).toEqual([
{ id: 1, title: 'Original', completed: false },
])
})
```
---
## Best Practices
✅ Disable retries in tests
✅ Use MSW for consistent mocking
✅ Test loading, success, and error states
✅ Test optimistic updates and rollbacks
✅ Use waitFor for async updates
✅ Prefill cache when testing with existing data
✅ Silence console errors in tests
❌ Don't test implementation details
❌ Don't mock TanStack Query internals

332
references/top-errors.md Normal file
View File

@@ -0,0 +1,332 @@
# Top TanStack Query Errors & Solutions
**Complete error reference with fixes**
---
## Error #1: Object Syntax Required
**Error Message**:
```
TypeError: useQuery is not a function
Property 'queryKey' does not exist on type...
```
**Why**:
v5 removed function overloads, only object syntax works
**Fix**:
```tsx
// ❌ v4 syntax
useQuery(['todos'], fetchTodos)
// ✅ v5 syntax
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
```
**Source**: [v5 Migration Guide](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#removed-overloads-in-favor-of-object-syntax)
---
## Error #2: Query Callbacks Not Working
**Error Message**:
```
Property 'onSuccess' does not exist on type 'UseQueryOptions'
```
**Why**:
`onSuccess`, `onError`, `onSettled` removed from queries (still work in mutations)
**Fix**:
```tsx
// ❌ v4
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
onSuccess: (data) => console.log(data)
})
// ✅ v5 - Use useEffect
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useEffect(() => {
if (data) console.log(data)
}, [data])
```
**Source**: [v5 Breaking Changes](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#callbacks-on-usequery-and-queryobserver-have-been-removed)
---
## Error #3: isLoading Always False
**Error Message**:
No error, but `isLoading` is false during initial fetch
**Why**:
v5 changed `isLoading` meaning: now `isPending && isFetching`
**Fix**:
```tsx
// ❌ v4
const { isLoading } = useQuery(...)
if (isLoading) return <Loading />
// ✅ v5
const { isPending } = useQuery(...)
if (isPending) return <Loading />
```
**Source**: [v5 Migration](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#isloading-and-isfetching-flags)
---
## Error #4: cacheTime Not Recognized
**Error Message**:
```
Property 'cacheTime' does not exist on type 'UseQueryOptions'
```
**Why**:
Renamed to `gcTime` (garbage collection time)
**Fix**:
```tsx
// ❌ v4
cacheTime: 1000 * 60 * 60
// ✅ v5
gcTime: 1000 * 60 * 60
```
**Source**: [v5 Migration](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#cachetime-has-been-replaced-by-gcTime)
---
## Error #5: useSuspenseQuery + enabled
**Error Message**:
```
Property 'enabled' does not exist on type 'UseSuspenseQueryOptions'
```
**Why**:
Suspense guarantees data is available, can't conditionally disable
**Fix**:
```tsx
// ❌ Wrong
useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
enabled: !!id,
})
// ✅ Correct: Conditional rendering
{id ? <TodoComponent id={id} /> : <div>No ID</div>}
```
**Source**: [GitHub Discussion #6206](https://github.com/TanStack/query/discussions/6206)
---
## Error #6: initialPageParam Required
**Error Message**:
```
Property 'initialPageParam' is missing in type 'UseInfiniteQueryOptions'
```
**Why**:
v5 requires explicit `initialPageParam` for infinite queries
**Fix**:
```tsx
// ❌ v4
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam = 0 }) => fetchProjects(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// ✅ v5
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // Required
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
```
**Source**: [v5 Migration](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#new-required-initialPageParam-option)
---
## Error #7: keepPreviousData Not Working
**Error Message**:
```
Property 'keepPreviousData' does not exist on type 'UseQueryOptions'
```
**Why**:
Replaced with `placeholderData` function
**Fix**:
```tsx
// ❌ v4
keepPreviousData: true
// ✅ v5
import { keepPreviousData } from '@tanstack/react-query'
placeholderData: keepPreviousData
```
**Source**: [v5 Migration](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#removed-keeppreviousdata-in-favor-of-placeholderdata-identity-function)
---
## Error #8: TypeScript Error Type
**Error Message**:
Type errors when handling non-Error objects
**Why**:
v5 defaults to `Error` type instead of `unknown`
**Fix**:
```tsx
// If throwing non-Error types, specify explicitly:
const { error } = useQuery<DataType, string>({
queryKey: ['data'],
queryFn: async () => {
if (fail) throw 'custom error string'
return data
},
})
// Better: Always throw Error objects
throw new Error('Custom error')
```
**Source**: [v5 Migration](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#typeerror-is-now-the-default-error)
---
## Error #9: Query Not Refetching
**Symptoms**:
Data never updates even when stale
**Why**:
Usually config issue - check staleTime, refetch options
**Fix**:
```tsx
// Check these settings
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 0, // Data stale immediately
refetchOnWindowFocus: true,
refetchOnMount: true,
refetchOnReconnect: true,
})
// Or manually refetch
const { refetch } = useQuery(...)
refetch()
// Or invalidate
queryClient.invalidateQueries({ queryKey: ['todos'] })
```
---
## Error #10: Mutations Not Invalidating
**Symptoms**:
UI doesn't update after mutation
**Why**:
Forgot to invalidate queries
**Fix**:
```tsx
useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }) // ✅ Required
},
})
```
---
## Error #11: Network Errors Not Caught
**Symptoms**:
App crashes on network errors
**Why**:
Not handling errors properly
**Fix**:
```tsx
// Always handle errors
const { data, error, isError } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`) // ✅ Throw errors
}
return response.json()
},
})
if (isError) return <div>Error: {error.message}</div>
```
---
## Error #12: Stale Closure in Callbacks
**Symptoms**:
Mutation callbacks use old data
**Why**:
Closure captures stale values
**Fix**:
```tsx
// ❌ Stale closure
const [value, setValue] = useState(0)
useMutation({
onSuccess: () => {
console.log(value) // Stale!
},
})
// ✅ Use functional update
useMutation({
onSuccess: () => {
setValue(prev => prev + 1) // Fresh value
},
})
```
---
## Quick Diagnosis Checklist
- [ ] Using v5 object syntax?
- [ ] Using `isPending` instead of `isLoading`?
- [ ] Using `gcTime` instead of `cacheTime`?
- [ ] No query callbacks (`onSuccess`, etc.)?
- [ ] `initialPageParam` present for infinite queries?
- [ ] Throwing errors in queryFn?
- [ ] Invalidating queries after mutations?
- [ ] Check DevTools for query state

View File

@@ -0,0 +1,291 @@
# TypeScript Patterns for TanStack Query
**Type-safe query and mutation patterns**
---
## 1. Basic Type Inference
```tsx
type Todo = {
id: number
title: string
completed: boolean
}
// ✅ Automatic type inference
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const response = await fetch('/api/todos')
return response.json()
},
})
// data is typed as Todo[] | undefined
```
---
## 2. Generic Query Hook
```tsx
function useEntity<T>(
endpoint: string,
id: number
) {
return useQuery({
queryKey: [endpoint, id],
queryFn: async (): Promise<T> => {
const response = await fetch(`/api/${endpoint}/${id}`)
return response.json()
},
})
}
// Usage
const { data } = useEntity<User>('users', 1)
// data: User | undefined
```
---
## 3. queryOptions with Type Safety
```tsx
export const todosQueryOptions = queryOptions({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const response = await fetch('/api/todos')
return response.json()
},
staleTime: 1000 * 60,
})
// Perfect type inference everywhere
useQuery(todosQueryOptions)
useSuspenseQuery(todosQueryOptions)
queryClient.prefetchQuery(todosQueryOptions)
```
---
## 4. Mutation with Types
```tsx
type CreateTodoInput = {
title: string
}
type CreateTodoResponse = Todo
const { mutate } = useMutation<
CreateTodoResponse, // TData
Error, // TError
CreateTodoInput, // TVariables
{ previous?: Todo[] } // TContext
>({
mutationFn: async (input) => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(input),
})
return response.json()
},
})
// Type-safe mutation
mutate({ title: 'New todo' })
```
---
## 5. Custom Error Types
```tsx
class ApiError extends Error {
constructor(
message: string,
public status: number,
public code: string
) {
super(message)
}
}
const { data, error } = useQuery<Todo[], ApiError>({
queryKey: ['todos'],
queryFn: async () => {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new ApiError(
'Failed to fetch',
response.status,
'FETCH_ERROR'
)
}
return response.json()
},
})
if (error) {
// error.status and error.code are typed
}
```
---
## 6. Zod Schema Validation
```tsx
import { z } from 'zod'
const TodoSchema = z.object({
id: z.number(),
title: z.string(),
completed: z.boolean(),
})
type Todo = z.infer<typeof TodoSchema>
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const response = await fetch('/api/todos')
const json = await response.json()
return TodoSchema.array().parse(json) // Runtime + compile time safety
},
})
```
---
## 7. Discriminated Union for Status
```tsx
type QueryState<T> =
| { status: 'pending'; data: undefined; error: null }
| { status: 'error'; data: undefined; error: Error }
| { status: 'success'; data: T; error: null }
function useTypedQuery<T>(
queryKey: string[],
queryFn: () => Promise<T>
): QueryState<T> {
const { data, status, error } = useQuery({ queryKey, queryFn })
return {
status,
data: data as any,
error: error as any,
}
}
// Usage with exhaustive checking
const result = useTypedQuery(['todos'], fetchTodos)
switch (result.status) {
case 'pending':
return <Loading />
case 'error':
return <Error error={result.error} /> // error is typed
case 'success':
return <TodoList todos={result.data} /> // data is typed
}
```
---
## 8. Type-Safe Query Keys
```tsx
// Define all query keys in one place
const queryKeys = {
todos: {
all: ['todos'] as const,
lists: () => [...queryKeys.todos.all, 'list'] as const,
list: (filters: TodoFilters) =>
[...queryKeys.todos.lists(), filters] as const,
details: () => [...queryKeys.todos.all, 'detail'] as const,
detail: (id: number) =>
[...queryKeys.todos.details(), id] as const,
},
}
// Usage
useQuery({
queryKey: queryKeys.todos.detail(1),
queryFn: () => fetchTodo(1),
})
queryClient.invalidateQueries({
queryKey: queryKeys.todos.all
})
```
---
## 9. Utility Types
```tsx
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'
// Extract query data type
type TodosQuery = UseQueryResult<Todo[]>
type TodoData = TodosQuery['data'] // Todo[] | undefined
// Extract mutation types
type AddTodoMutation = UseMutationResult<
Todo,
Error,
CreateTodoInput
>
```
---
## 10. Strict Null Checks
```tsx
const { data } = useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
})
// ❌ TypeScript error if strictNullChecks enabled
const title = data.title
// ✅ Proper null handling
const title = data?.title ?? 'No title'
// ✅ Type guard
if (data) {
const title = data.title // data is Todo, not undefined
}
```
---
## 11. SuspenseQuery Types
```tsx
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// data is ALWAYS Todo[], never undefined
// No need for undefined checks with suspense
data.map(todo => todo.title) // ✅ Safe
```
---
## Best Practices
✅ Always type queryFn return value
✅ Use const assertions for query keys
✅ Leverage queryOptions for reusability
✅ Use Zod for runtime + compile time validation
✅ Enable strict null checks
✅ Create type-safe query key factories
✅ Use custom error types for better error handling

View File

@@ -0,0 +1,231 @@
# TanStack Query v4 to v5 Migration Guide
**Complete migration checklist for upgrading from React Query v4 to TanStack Query v5**
---
## Breaking Changes Summary
### 1. Object Syntax Required ⚠️
**v4** allowed multiple signatures:
```tsx
useQuery(['todos'], fetchTodos, { staleTime: 5000 })
useQuery(['todos'], fetchTodos)
useQuery(queryOptions)
```
**v5** only supports object syntax:
```tsx
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000
})
```
**Migration**: Use codemod or manual update
```bash
npx @tanstack/react-query-codemod v5/remove-overloads
```
### 2. Query Callbacks Removed ⚠️
**Removed from queries** (still work in mutations):
- `onSuccess`
- `onError`
- `onSettled`
**v4**:
```tsx
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
onSuccess: (data) => console.log(data) // ❌ Removed
})
```
**v5** - Use `useEffect`:
```tsx
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useEffect(() => {
if (data) {
console.log(data)
}
}, [data])
```
**Mutation callbacks still work**:
```tsx
useMutation({
mutationFn: addTodo,
onSuccess: () => {} // ✅ Still works
})
```
### 3. `isLoading` → `isPending` ⚠️
**v4**: `isLoading` meant "no data yet"
**v5**: `isPending` means "no data yet", `isLoading` = `isPending && isFetching`
```tsx
// v4
const { data, isLoading } = useQuery(...)
if (isLoading) return <Loading />
// v5
const { data, isPending } = useQuery(...)
if (isPending) return <Loading />
```
### 4. `cacheTime` → `gcTime` ⚠️
```tsx
// v4
cacheTime: 1000 * 60 * 60
// v5
gcTime: 1000 * 60 * 60
```
### 5. `initialPageParam` Required for Infinite Queries ⚠️
```tsx
// v4
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam = 0 }) => fetchProjects(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// v5
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // ✅ Required
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
```
### 6. `keepPreviousData` → `placeholderData` ⚠️
```tsx
// v4
keepPreviousData: true
// v5
import { keepPreviousData } from '@tanstack/react-query'
placeholderData: keepPreviousData
```
### 7. `useErrorBoundary` → `throwOnError` ⚠️
```tsx
// v4
useErrorBoundary: true
// v5
throwOnError: true
// Or conditional:
throwOnError: (error) => error.status >= 500
```
### 8. Error Type Default Changed
**v4**: `error: unknown`
**v5**: `error: Error`
If throwing non-Error types:
```tsx
const { error } = useQuery<DataType, string>({
queryKey: ['data'],
queryFn: async () => {
if (fail) throw 'custom string error'
return data
},
})
```
---
## Step-by-Step Migration
### Step 1: Update Packages
```bash
npm install @tanstack/react-query@latest
npm install -D @tanstack/react-query-devtools@latest
```
### Step 2: Run Codemods
```bash
# Remove function overloads
npx @tanstack/react-query-codemod v5/remove-overloads
# Replace removed/renamed methods
npx @tanstack/react-query-codemod v5/rename-properties
```
### Step 3: Manual Fixes
1. Replace query callbacks with useEffect
2. Replace `isLoading` with `isPending`
3. Replace `cacheTime` with `gcTime`
4. Add `initialPageParam` to infinite queries
5. Replace `keepPreviousData` with `placeholderData`
### Step 4: TypeScript Fixes
Update type imports:
```tsx
// v4
import type { UseQueryResult } from 'react-query'
// v5
import type { UseQueryResult } from '@tanstack/react-query'
```
### Step 5: Test Thoroughly
- Check all queries work
- Verify mutations invalidate correctly
- Test error handling
- Check infinite queries
- Verify TypeScript types
---
## Common Migration Issues
### Issue: Callbacks not firing
**Cause**: Query callbacks removed
**Fix**: Use useEffect or move to mutations
### Issue: isLoading always false
**Cause**: Meaning changed
**Fix**: Use isPending for initial load
### Issue: cacheTime not recognized
**Cause**: Renamed
**Fix**: Use gcTime
### Issue: infinite query type error
**Cause**: initialPageParam required
**Fix**: Add initialPageParam
---
## Full Codemod List
```bash
# All v5 codemods
npx @tanstack/react-query-codemod v5/remove-overloads
npx @tanstack/react-query-codemod v5/rename-properties
npx @tanstack/react-query-codemod v5/replace-imports
```
**Note**: Codemods may not catch everything - manual review required!