Initial commit
This commit is contained in:
304
references/best-practices.md
Normal file
304
references/best-practices.md
Normal 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
|
||||
271
references/common-patterns.md
Normal file
271
references/common-patterns.md
Normal 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)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
26
references/example-reference.md
Normal file
26
references/example-reference.md
Normal 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
282
references/testing.md
Normal 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
332
references/top-errors.md
Normal 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
|
||||
291
references/typescript-patterns.md
Normal file
291
references/typescript-patterns.md
Normal 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
|
||||
231
references/v4-to-v5-migration.md
Normal file
231
references/v4-to-v5-migration.md
Normal 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!
|
||||
Reference in New Issue
Block a user