Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "tanstack-query",
|
||||
"description": "Manage server state in React with TanStack Query v5. Set up queries with useQuery, mutations with useMutation, configure QueryClient caching strategies, implement optimistic updates, and handle infinite scroll with useInfiniteQuery. Use when: setting up data fetching in React projects, migrating from v4 to v5, or fixing object syntax required errors, query callbacks removed issues, cacheTime renamed to gcTime, isPending vs isLoading confusion, keepPreviousData removed problems.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Jeremy Dawes",
|
||||
"email": "jeremy@jezweb.net"
|
||||
},
|
||||
"skills": [
|
||||
"./"
|
||||
]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# tanstack-query
|
||||
|
||||
Manage server state in React with TanStack Query v5. Set up queries with useQuery, mutations with useMutation, configure QueryClient caching strategies, implement optimistic updates, and handle infinite scroll with useInfiniteQuery. Use when: setting up data fetching in React projects, migrating from v4 to v5, or fixing object syntax required errors, query callbacks removed issues, cacheTime renamed to gcTime, isPending vs isLoading confusion, keepPreviousData removed problems.
|
||||
14
assets/example-template.txt
Normal file
14
assets/example-template.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
[TODO: Example Template File]
|
||||
|
||||
[TODO: This directory contains files that will be used in the OUTPUT that Claude produces.]
|
||||
|
||||
[TODO: Examples:]
|
||||
- Templates (.html, .tsx, .md)
|
||||
- Images (.png, .svg)
|
||||
- Fonts (.ttf, .woff)
|
||||
- Boilerplate code
|
||||
- Configuration file templates
|
||||
|
||||
[TODO: Delete this file and add your actual assets]
|
||||
|
||||
These files are NOT loaded into context. They are copied or used directly in the final output.
|
||||
121
plugin.lock.json
Normal file
121
plugin.lock.json
Normal file
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||
"pluginId": "gh:jezweb/claude-skills:skills/tanstack-query",
|
||||
"normalized": {
|
||||
"repo": null,
|
||||
"ref": "refs/tags/v20251128.0",
|
||||
"commit": "7517b232519a6a21afc7f34e50e8c2220ad34df8",
|
||||
"treeHash": "0815c2a376e7c6c94abcbd69acb768254bdaa2f854cddf5e48ffbfdc487cd94a",
|
||||
"generatedAt": "2025-11-28T10:18:59.444218Z",
|
||||
"toolVersion": "publish_plugins.py@0.2.0"
|
||||
},
|
||||
"origin": {
|
||||
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||
"branch": "master",
|
||||
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "tanstack-query",
|
||||
"description": "Manage server state in React with TanStack Query v5. Set up queries with useQuery, mutations with useMutation, configure QueryClient caching strategies, implement optimistic updates, and handle infinite scroll with useInfiniteQuery. Use when: setting up data fetching in React projects, migrating from v4 to v5, or fixing object syntax required errors, query callbacks removed issues, cacheTime renamed to gcTime, isPending vs isLoading confusion, keepPreviousData removed problems.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"content": {
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "f3e2d28d290a6b56cd5b275b50b24df76f020936e19834f31aef12296e7bab2e"
|
||||
},
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"sha256": "aa7e8ded6c582508ea0c51aedd4e55af20e6ac6b9d465151f4f4fe950b83ae71"
|
||||
},
|
||||
{
|
||||
"path": "references/example-reference.md",
|
||||
"sha256": "77c788d727d05d6479a61d6652b132e43882ffc67c145bb46ba880567d83f7f8"
|
||||
},
|
||||
{
|
||||
"path": "references/best-practices.md",
|
||||
"sha256": "836785c9badd60ee317da20d3d9fe739a4ad37161a3cd54fc59653ec8ede3e89"
|
||||
},
|
||||
{
|
||||
"path": "references/top-errors.md",
|
||||
"sha256": "8f666a2e203fff2fdbffff7d5cd4287cb14f54b405dfeb97ee0849265109d2d1"
|
||||
},
|
||||
{
|
||||
"path": "references/testing.md",
|
||||
"sha256": "6994f18ec1780a04fa45ef4f9528791762ac8a149419819ce41399500eb0c313"
|
||||
},
|
||||
{
|
||||
"path": "references/typescript-patterns.md",
|
||||
"sha256": "e8fe6ccbebd2680b9fb3a97244b8ff10305e265e0635280501ad1fd45bf63aa7"
|
||||
},
|
||||
{
|
||||
"path": "references/v4-to-v5-migration.md",
|
||||
"sha256": "2b6d99658cb8d8e5cda6ab9b392f656d54bf4b3f6f55c29ec43cc8e33e618ccc"
|
||||
},
|
||||
{
|
||||
"path": "references/common-patterns.md",
|
||||
"sha256": "ff3bb5d5c1ac54dcd8ac81a915f5bb1c5e8c86cef52611b1e2958592e0f090b6"
|
||||
},
|
||||
{
|
||||
"path": "scripts/example-script.sh",
|
||||
"sha256": "83d2b09d044811608e17cbd8e66d993b1e9998c7bd3379a42ab81fbdba973e0e"
|
||||
},
|
||||
{
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"sha256": "31f27550cba829d7c2a7acfee277502a985fa32aa1482db49aaf11bdc0b5c3f9"
|
||||
},
|
||||
{
|
||||
"path": "templates/error-boundary.tsx",
|
||||
"sha256": "41dfc95f04125b7e4fde7d3ae9e306102feb13d95334c381b0e9d15403165714"
|
||||
},
|
||||
{
|
||||
"path": "templates/provider-setup.tsx",
|
||||
"sha256": "a6b321bf85371b8943e2736516d044dfd573bbcba88f41c2d9e81419de64e59b"
|
||||
},
|
||||
{
|
||||
"path": "templates/use-mutation-basic.tsx",
|
||||
"sha256": "2147e4ac021f9bac966676e6a127645f31f5e68f9ee52258d4fb289b4b76b202"
|
||||
},
|
||||
{
|
||||
"path": "templates/package.json",
|
||||
"sha256": "9f2dd40ddfb316c0f8b2eb7a03fdb76f296a0d7c6e7ee572390a6b1864b8e880"
|
||||
},
|
||||
{
|
||||
"path": "templates/devtools-setup.tsx",
|
||||
"sha256": "79386b249eec392a5ff8a12eadc3ff786e02ce036bb932a09961e4293e60593d"
|
||||
},
|
||||
{
|
||||
"path": "templates/use-query-basic.tsx",
|
||||
"sha256": "d7659c9962358806dd43f1bf000d631435b38d52b13ee33097ba033787917763"
|
||||
},
|
||||
{
|
||||
"path": "templates/custom-hooks-pattern.tsx",
|
||||
"sha256": "8a6b2d68b5c33a5b780dfa2f3cec6fb95fadb10bb6a8edf98e7834e2f3a8bc82"
|
||||
},
|
||||
{
|
||||
"path": "templates/use-infinite-query.tsx",
|
||||
"sha256": "30e461d9f9adef96cc9f8a363478a1b0b95dc26a8910dc63cc350dd4a303f2ae"
|
||||
},
|
||||
{
|
||||
"path": "templates/use-mutation-optimistic.tsx",
|
||||
"sha256": "ca7b7b5d1f9a495d6ef98e2c8f18c997b7b4d58cc6b3c56bfd86d81ffd839384"
|
||||
},
|
||||
{
|
||||
"path": "templates/query-client-config.ts",
|
||||
"sha256": "7fa7d20cf9ce4b342493f9507011c778834502806653bad34063053e51954760"
|
||||
},
|
||||
{
|
||||
"path": "assets/example-template.txt",
|
||||
"sha256": "3f725c80d70847fd8272bf1400515ba753f12f98f3b294d09e50b54b4c1b024a"
|
||||
}
|
||||
],
|
||||
"dirSha256": "0815c2a376e7c6c94abcbd69acb768254bdaa2f854cddf5e48ffbfdc487cd94a"
|
||||
},
|
||||
"security": {
|
||||
"scannedAt": null,
|
||||
"scannerVersion": null,
|
||||
"flags": []
|
||||
}
|
||||
}
|
||||
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!
|
||||
15
scripts/example-script.sh
Executable file
15
scripts/example-script.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# [TODO: Script Name]
|
||||
# [TODO: Brief description of what this script does]
|
||||
|
||||
# Example script structure - delete if not needed
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# [TODO: Add your script logic here]
|
||||
|
||||
echo "Example script - replace or delete this file"
|
||||
|
||||
# Usage:
|
||||
# ./scripts/example-script.sh [args]
|
||||
281
templates/custom-hooks-pattern.tsx
Normal file
281
templates/custom-hooks-pattern.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
// src/hooks/useUsers.ts - Example of advanced custom hooks pattern
|
||||
import { useQuery, useMutation, useQueryClient, queryOptions } from '@tanstack/react-query'
|
||||
|
||||
/**
|
||||
* Type definitions
|
||||
*/
|
||||
export type User = {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
}
|
||||
|
||||
export type CreateUserInput = Omit<User, 'id'>
|
||||
export type UpdateUserInput = Partial<User> & { id: number }
|
||||
|
||||
/**
|
||||
* API functions - centralized network logic
|
||||
*/
|
||||
const userApi = {
|
||||
getAll: async (): Promise<User[]> => {
|
||||
const response = await fetch('https://jsonplaceholder.typicode.com/users')
|
||||
if (!response.ok) throw new Error('Failed to fetch users')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
getById: async (id: number): Promise<User> => {
|
||||
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
|
||||
if (!response.ok) throw new Error(`Failed to fetch user ${id}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
create: async (user: CreateUserInput): Promise<User> => {
|
||||
const response = await fetch('https://jsonplaceholder.typicode.com/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(user),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to create user')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
update: async ({ id, ...updates }: UpdateUserInput): Promise<User> => {
|
||||
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to update user')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to delete user')
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options factories (v5 best practice)
|
||||
*
|
||||
* Benefits:
|
||||
* - Type-safe reusable query configurations
|
||||
* - DRY principle - single source of truth
|
||||
* - Works with useQuery, useSuspenseQuery, prefetchQuery
|
||||
* - Easier testing and mocking
|
||||
*/
|
||||
export const usersQueryOptions = queryOptions({
|
||||
queryKey: ['users'],
|
||||
queryFn: userApi.getAll,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
|
||||
export const userQueryOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: ['users', id],
|
||||
queryFn: () => userApi.getById(id),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
|
||||
/**
|
||||
* Query Hooks
|
||||
*/
|
||||
export function useUsers() {
|
||||
return useQuery(usersQueryOptions)
|
||||
}
|
||||
|
||||
export function useUser(id: number) {
|
||||
return useQuery(userQueryOptions(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced: Search/Filter Hook
|
||||
*
|
||||
* Demonstrates dependent query with filtering
|
||||
*/
|
||||
export function useUserSearch(searchTerm: string) {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'search', searchTerm],
|
||||
queryFn: async () => {
|
||||
const users = await userApi.getAll()
|
||||
return users.filter(
|
||||
(user) =>
|
||||
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
},
|
||||
enabled: searchTerm.length >= 2, // Only search if 2+ characters
|
||||
staleTime: 1000 * 30, // 30 seconds for search results
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation Hooks
|
||||
*/
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: userApi.create,
|
||||
onSuccess: (newUser) => {
|
||||
// Update cache with new user
|
||||
queryClient.setQueryData<User[]>(['users'], (old = []) => [...old, newUser])
|
||||
|
||||
// Invalidate to refetch and ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: userApi.update,
|
||||
onSuccess: (updatedUser) => {
|
||||
// Update individual user cache
|
||||
queryClient.setQueryData(['users', updatedUser.id], updatedUser)
|
||||
|
||||
// Update user in list
|
||||
queryClient.setQueryData<User[]>(['users'], (old = []) =>
|
||||
old.map((user) => (user.id === updatedUser.id ? updatedUser : user))
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteUser() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: userApi.delete,
|
||||
onSuccess: (_, deletedId) => {
|
||||
// Remove from cache
|
||||
queryClient.setQueryData<User[]>(['users'], (old = []) =>
|
||||
old.filter((user) => user.id !== deletedId)
|
||||
)
|
||||
|
||||
// Remove individual query
|
||||
queryClient.removeQueries({ queryKey: ['users', deletedId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced: Prefetch Hook
|
||||
*
|
||||
* Prefetch user details on hover for instant navigation
|
||||
*/
|
||||
export function usePrefetchUser() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return (id: number) => {
|
||||
queryClient.prefetchQuery(userQueryOptions(id))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component Usage Examples
|
||||
*/
|
||||
|
||||
// Example 1: List all users
|
||||
export function UserList() {
|
||||
const { data: users, isPending, isError, error } = useUsers()
|
||||
const prefetchUser = usePrefetchUser()
|
||||
|
||||
if (isPending) return <div>Loading...</div>
|
||||
if (isError) return <div>Error: {error.message}</div>
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{users.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
onMouseEnter={() => prefetchUser(user.id)} // Prefetch on hover
|
||||
>
|
||||
<a href={`/users/${user.id}`}>{user.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// Example 2: User detail page
|
||||
export function UserDetail({ id }: { id: number }) {
|
||||
const { data: user, isPending } = useUser(id)
|
||||
const { mutate: updateUser, isPending: isUpdating } = useUpdateUser()
|
||||
const { mutate: deleteUser } = useDeleteUser()
|
||||
|
||||
if (isPending) return <div>Loading...</div>
|
||||
if (!user) return <div>User not found</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{user.name}</h1>
|
||||
<p>Email: {user.email}</p>
|
||||
<p>Phone: {user.phone}</p>
|
||||
|
||||
<button
|
||||
onClick={() => updateUser({ id: user.id, name: 'Updated Name' })}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Update Name
|
||||
</button>
|
||||
|
||||
<button onClick={() => deleteUser(user.id)}>
|
||||
Delete User
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Example 3: Search users
|
||||
export function UserSearch() {
|
||||
const [search, setSearch] = useState('')
|
||||
const { data: results, isFetching } = useUserSearch(search)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
|
||||
{isFetching && <span>Searching...</span>}
|
||||
|
||||
{results && (
|
||||
<ul>
|
||||
{results.map((user) => (
|
||||
<li key={user.id}>{user.name} - {user.email}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Key patterns demonstrated:
|
||||
*
|
||||
* 1. API Layer: Centralized fetch functions
|
||||
* 2. Query Options Factories: Reusable queryOptions
|
||||
* 3. Custom Hooks: Encapsulate query logic
|
||||
* 4. Mutation Hooks: Encapsulate mutation logic
|
||||
* 5. Cache Updates: setQueryData, invalidateQueries, removeQueries
|
||||
* 6. Prefetching: Improve perceived performance
|
||||
* 7. Conditional Queries: enabled option
|
||||
* 8. Search/Filter: Derived queries from base data
|
||||
*
|
||||
* Benefits:
|
||||
* ✅ Type safety throughout
|
||||
* ✅ Easy to test (mock API layer)
|
||||
* ✅ Reusable across components
|
||||
* ✅ Consistent error handling
|
||||
* ✅ Optimized caching strategy
|
||||
* ✅ Better code organization
|
||||
*/
|
||||
248
templates/devtools-setup.tsx
Normal file
248
templates/devtools-setup.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
// src/main.tsx - Complete DevTools Setup
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import App from './App'
|
||||
|
||||
/**
|
||||
* QueryClient with DevTools-friendly configuration
|
||||
*/
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 60,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
|
||||
{/*
|
||||
ReactQueryDevtools Configuration
|
||||
|
||||
IMPORTANT: DevTools are automatically tree-shaken in production
|
||||
Safe to leave in code, won't appear in production bundle
|
||||
*/}
|
||||
<ReactQueryDevtools
|
||||
// Start collapsed (default: false)
|
||||
initialIsOpen={false}
|
||||
|
||||
// Button position on screen
|
||||
buttonPosition="bottom-right" // "top-left" | "top-right" | "bottom-left" | "bottom-right"
|
||||
|
||||
// Panel position when open
|
||||
position="bottom" // "top" | "bottom" | "left" | "right"
|
||||
|
||||
// Custom styles for toggle button
|
||||
toggleButtonProps={{
|
||||
style: {
|
||||
marginBottom: '4rem', // Move up if button overlaps content
|
||||
marginRight: '1rem',
|
||||
},
|
||||
}}
|
||||
|
||||
// Custom styles for panel
|
||||
panelProps={{
|
||||
style: {
|
||||
height: '400px', // Custom panel height
|
||||
},
|
||||
}}
|
||||
|
||||
// Add keyboard shortcut (optional)
|
||||
// Default: None, but you can add custom handler
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
/**
|
||||
* Advanced: Conditional DevTools (explicit dev check)
|
||||
*
|
||||
* DevTools are already removed in production, but can add explicit check
|
||||
*/
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
{import.meta.env.DEV && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
/**
|
||||
* Advanced: Custom Toggle Button
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
|
||||
function AppWithCustomDevTools() {
|
||||
const [showDevTools, setShowDevTools] = useState(false)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
|
||||
{/* Custom toggle button */}
|
||||
<button
|
||||
onClick={() => setShowDevTools(!showDevTools)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '1rem',
|
||||
right: '1rem',
|
||||
zIndex: 99999,
|
||||
}}
|
||||
>
|
||||
{showDevTools ? 'Hide' : 'Show'} DevTools
|
||||
</button>
|
||||
|
||||
{showDevTools && <ReactQueryDevtools initialIsOpen={true} />}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* DevTools Features (what you can do):
|
||||
*
|
||||
* 1. View all queries: See queryKey, status, data, error
|
||||
* 2. Inspect cache: View cached data for each query
|
||||
* 3. Manual refetch: Force refetch any query
|
||||
* 4. View mutations: See in-flight and completed mutations
|
||||
* 5. Query invalidation: Manually invalidate queries
|
||||
* 6. Explorer mode: Navigate query hierarchy
|
||||
* 7. Time travel: See query state over time
|
||||
* 8. Export state: Download current cache for debugging
|
||||
*
|
||||
* DevTools Panel Sections:
|
||||
* - Queries: All active/cached queries
|
||||
* - Mutations: Recent mutations
|
||||
* - Query Cache: Full cache state
|
||||
* - Mutation Cache: Mutation history
|
||||
* - Settings: DevTools configuration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Debugging with DevTools
|
||||
*/
|
||||
|
||||
// Example: Check if query is being cached correctly
|
||||
function DebugQueryCaching() {
|
||||
const { data, dataUpdatedAt, isFetching } = useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Last updated: {new Date(dataUpdatedAt).toLocaleTimeString()}</p>
|
||||
<p>Is fetching: {isFetching ? 'Yes' : 'No'}</p>
|
||||
{/* Open DevTools to see:
|
||||
- Query status (fresh, fetching, stale)
|
||||
- Cache data
|
||||
- Refetch behavior
|
||||
*/}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Example: Debug why query keeps refetching
|
||||
function DebugRefetchingIssue() {
|
||||
const { data, isFetching, isRefetching } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: fetchUsers,
|
||||
// Check in DevTools if these settings are correct:
|
||||
staleTime: 0, // ❌ Data always stale, will refetch frequently
|
||||
refetchOnWindowFocus: true, // ❌ Refetches on every focus
|
||||
refetchOnMount: true, // ❌ Refetches on every mount
|
||||
})
|
||||
|
||||
// DevTools will show you:
|
||||
// - How many times query refetched
|
||||
// - When it refetched (mount, focus, reconnect)
|
||||
// - Current staleTime and gcTime settings
|
||||
|
||||
return <div>Fetching: {isFetching ? 'Yes' : 'No'}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* Production DevTools (optional, separate package)
|
||||
*
|
||||
* For debugging production issues remotely
|
||||
* npm install @tanstack/react-query-devtools-production
|
||||
*/
|
||||
import { ReactQueryDevtools as ReactQueryDevtoolsProd } from '@tanstack/react-query-devtools-production'
|
||||
|
||||
function AppWithProductionDevTools() {
|
||||
const [showDevTools, setShowDevTools] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Load production devtools on demand
|
||||
// Only when user presses keyboard shortcut or secret URL
|
||||
if (showDevTools) {
|
||||
import('@tanstack/react-query-devtools-production').then((module) => {
|
||||
// Module loaded
|
||||
})
|
||||
}
|
||||
}, [showDevTools])
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
{showDevTools && <ReactQueryDevtoolsProd />}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard Shortcuts (DIY)
|
||||
*
|
||||
* Add custom keyboard shortcut to toggle DevTools
|
||||
*/
|
||||
function AppWithKeyboardShortcut() {
|
||||
const [showDevTools, setShowDevTools] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
// Ctrl/Cmd + Shift + D
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'd') {
|
||||
e.preventDefault()
|
||||
setShowDevTools((prev) => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress)
|
||||
return () => window.removeEventListener('keydown', handleKeyPress)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
{showDevTools && <ReactQueryDevtools />}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best Practices:
|
||||
*
|
||||
* ✅ Keep DevTools in code (tree-shaken in production)
|
||||
* ✅ Start with initialIsOpen={false} to avoid distraction
|
||||
* ✅ Use DevTools to debug cache issues
|
||||
* ✅ Check DevTools when queries refetch unexpectedly
|
||||
* ✅ Export state for bug reports
|
||||
*
|
||||
* ❌ Don't ship production devtools without authentication
|
||||
* ❌ Don't rely on DevTools for production monitoring
|
||||
* ❌ Don't expose sensitive data in cache (use select to filter)
|
||||
*
|
||||
* Performance:
|
||||
* - DevTools have minimal performance impact in dev
|
||||
* - Completely removed in production builds
|
||||
* - No runtime overhead when not open
|
||||
*/
|
||||
243
templates/error-boundary.tsx
Normal file
243
templates/error-boundary.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
// src/components/ErrorBoundary.tsx
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { QueryErrorResetBoundary } from '@tanstack/react-query'
|
||||
|
||||
/**
|
||||
* Props and State types
|
||||
*/
|
||||
type ErrorBoundaryProps = {
|
||||
children: ReactNode
|
||||
fallback?: (error: Error, reset: () => void) => ReactNode
|
||||
}
|
||||
|
||||
type ErrorBoundaryState = {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* React Error Boundary Class Component
|
||||
*
|
||||
* Required because error boundaries must be class components
|
||||
*/
|
||||
class ErrorBoundaryClass extends Component<
|
||||
ErrorBoundaryProps & { onReset?: () => void },
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps & { onReset?: () => void }) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log error to error reporting service
|
||||
console.error('Error caught by boundary:', error, errorInfo)
|
||||
|
||||
// Example: Send to Sentry, LogRocket, etc.
|
||||
// Sentry.captureException(error, { contexts: { react: errorInfo } })
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
// Call TanStack Query reset if provided
|
||||
this.props.onReset?.()
|
||||
|
||||
// Reset error boundary state
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
// Use custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.handleReset)
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '2rem',
|
||||
border: '2px solid #ef4444',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#fee',
|
||||
}}
|
||||
>
|
||||
<h2>Something went wrong</h2>
|
||||
<details style={{ whiteSpace: 'pre-wrap', marginTop: '1rem' }}>
|
||||
<summary>Error details</summary>
|
||||
{this.state.error.message}
|
||||
{this.state.error.stack && (
|
||||
<pre style={{ marginTop: '1rem', fontSize: '0.875rem' }}>
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
)}
|
||||
</details>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary with TanStack Query Reset
|
||||
*
|
||||
* Wraps components and catches errors thrown by queries
|
||||
* with throwOnError: true
|
||||
*/
|
||||
export function ErrorBoundary({ children, fallback }: ErrorBoundaryProps) {
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundaryClass onReset={reset} fallback={fallback}>
|
||||
{children}
|
||||
</ErrorBoundaryClass>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage Examples
|
||||
*/
|
||||
|
||||
// Example 1: Wrap entire app
|
||||
export function AppWithErrorBoundary() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
// Example 2: Wrap specific features
|
||||
export function UserProfileWithErrorBoundary() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<UserProfile />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
// Example 3: Custom error UI
|
||||
export function CustomErrorBoundary({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<div className="error-container">
|
||||
<h1>Oops!</h1>
|
||||
<p>We encountered an error: {error.message}</p>
|
||||
<button onClick={reset}>Retry</button>
|
||||
<a href="/">Go Home</a>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Using throwOnError with Queries
|
||||
*
|
||||
* Queries can throw errors to error boundaries
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
// Example 1: Always throw errors
|
||||
function UserData({ id }: { id: number }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['user', id],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/users/${id}`)
|
||||
if (!response.ok) throw new Error('User not found')
|
||||
return response.json()
|
||||
},
|
||||
throwOnError: true, // Throw to error boundary
|
||||
})
|
||||
|
||||
return <div>{data.name}</div>
|
||||
}
|
||||
|
||||
// Example 2: Conditional throwing (only server errors)
|
||||
function ConditionalErrorThrowing({ id }: { id: number }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['user', id],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/users/${id}`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return response.json()
|
||||
},
|
||||
throwOnError: (error) => {
|
||||
// Only throw 5xx server errors to boundary
|
||||
// Handle 4xx client errors locally
|
||||
return error.message.includes('5')
|
||||
},
|
||||
})
|
||||
|
||||
return <div>{data?.name ?? 'Not found'}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiple Error Boundaries (Layered)
|
||||
*
|
||||
* Place boundaries at different levels for granular error handling
|
||||
*/
|
||||
export function LayeredErrorBoundaries() {
|
||||
return (
|
||||
// App-level boundary
|
||||
<ErrorBoundary fallback={(error) => <AppCrashScreen error={error} />}>
|
||||
<Header />
|
||||
|
||||
{/* Feature-level boundary */}
|
||||
<ErrorBoundary fallback={(error) => <FeatureError error={error} />}>
|
||||
<UserProfile />
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* Another feature boundary */}
|
||||
<ErrorBoundary>
|
||||
<TodoList />
|
||||
</ErrorBoundary>
|
||||
|
||||
<Footer />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Key concepts:
|
||||
*
|
||||
* 1. QueryErrorResetBoundary: Provides reset function for TanStack Query
|
||||
* 2. throwOnError: Makes query throw errors to boundary
|
||||
* 3. Layered boundaries: Isolate failures to specific features
|
||||
* 4. Custom fallbacks: Control error UI per boundary
|
||||
* 5. Error logging: componentDidCatch for monitoring
|
||||
*
|
||||
* Best practices:
|
||||
* ✅ Always wrap app in error boundary
|
||||
* ✅ Use throwOnError for critical errors only
|
||||
* ✅ Provide helpful error messages to users
|
||||
* ✅ Log errors to monitoring service
|
||||
* ✅ Offer reset/retry functionality
|
||||
* ❌ Don't catch all errors - use local error states when appropriate
|
||||
* ❌ Don't throw for expected errors (404, validation)
|
||||
*/
|
||||
31
templates/package.json
Normal file
31
templates/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "my-app-with-tanstack-query",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@tanstack/react-query": "^5.90.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/eslint-plugin-query": "^5.90.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.1"
|
||||
}
|
||||
}
|
||||
50
templates/provider-setup.tsx
Normal file
50
templates/provider-setup.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// src/main.tsx
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { queryClient } from './lib/query-client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
{/* DevTools are automatically removed in production builds */}
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-right"
|
||||
position="bottom"
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
/**
|
||||
* Important notes:
|
||||
*
|
||||
* 1. QueryClientProvider must wrap all components that use TanStack Query hooks
|
||||
* 2. DevTools must be inside the provider
|
||||
* 3. DevTools are tree-shaken in production (safe to leave in code)
|
||||
* 4. Only create ONE QueryClient instance for entire app (imported from query-client.ts)
|
||||
*
|
||||
* DevTools configuration options:
|
||||
* - initialIsOpen: true/false - Start open or closed
|
||||
* - buttonPosition: "top-left" | "top-right" | "bottom-left" | "bottom-right"
|
||||
* - position: "top" | "bottom" | "left" | "right"
|
||||
* - toggleButtonProps: Custom button styles
|
||||
* - panelProps: Custom panel styles
|
||||
*
|
||||
* Example with custom styles:
|
||||
* <ReactQueryDevtools
|
||||
* initialIsOpen={false}
|
||||
* buttonPosition="bottom-right"
|
||||
* toggleButtonProps={{
|
||||
* style: { marginBottom: '4rem' }
|
||||
* }}
|
||||
* panelProps={{
|
||||
* style: { height: '500px' }
|
||||
* }}
|
||||
* />
|
||||
*/
|
||||
72
templates/query-client-config.ts
Normal file
72
templates/query-client-config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// src/lib/query-client.ts
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
/**
|
||||
* QueryClient configuration for TanStack Query v5
|
||||
*
|
||||
* Key settings:
|
||||
* - staleTime: How long data is fresh (won't refetch)
|
||||
* - gcTime: How long inactive data stays in cache (garbage collection time)
|
||||
* - retry: Number of retry attempts on failure
|
||||
* - refetchOnWindowFocus: Refetch when window regains focus
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Data is fresh for 5 minutes (won't refetch during this time)
|
||||
staleTime: 1000 * 60 * 5,
|
||||
|
||||
// Inactive data stays in cache for 1 hour before garbage collection
|
||||
gcTime: 1000 * 60 * 60,
|
||||
|
||||
// Retry failed requests with smart logic
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on 404s
|
||||
if (error instanceof Response && error.status === 404) {
|
||||
return false
|
||||
}
|
||||
// Retry up to 3 times for other errors
|
||||
return failureCount < 3
|
||||
},
|
||||
|
||||
// Don't refetch on window focus (can be annoying during dev)
|
||||
// Set to true for real-time data (stock prices, notifications)
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
// Refetch when network reconnects
|
||||
refetchOnReconnect: true,
|
||||
|
||||
// Refetch on component mount if data is stale
|
||||
refetchOnMount: true,
|
||||
},
|
||||
mutations: {
|
||||
// Don't retry mutations by default (usually not wanted)
|
||||
retry: 0,
|
||||
|
||||
// Global mutation error handler (optional)
|
||||
onError: (error) => {
|
||||
console.error('Mutation error:', error)
|
||||
// Add global error handling here (toast, alert, etc.)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Adjust these settings based on your needs:
|
||||
*
|
||||
* For real-time data (stock prices, notifications):
|
||||
* - staleTime: 0 (always stale, refetch frequently)
|
||||
* - refetchOnWindowFocus: true
|
||||
* - refetchInterval: 1000 * 30 (refetch every 30s)
|
||||
*
|
||||
* For static data (user settings, app config):
|
||||
* - staleTime: Infinity (never stale)
|
||||
* - refetchOnWindowFocus: false
|
||||
* - refetchOnMount: false
|
||||
*
|
||||
* For moderate data (todos, posts):
|
||||
* - staleTime: 1000 * 60 * 5 (5 minutes)
|
||||
* - refetchOnWindowFocus: false
|
||||
* - refetchOnMount: true
|
||||
*/
|
||||
214
templates/use-infinite-query.tsx
Normal file
214
templates/use-infinite-query.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
// src/hooks/useInfiniteTodos.ts
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { Todo } from './useTodos'
|
||||
|
||||
/**
|
||||
* Paginated response type
|
||||
*/
|
||||
type TodosPage = {
|
||||
data: Todo[]
|
||||
nextCursor: number | null
|
||||
previousCursor: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch paginated todos
|
||||
*
|
||||
* In real API: cursor would be offset, page number, or last item ID
|
||||
*/
|
||||
async function fetchTodosPage({ pageParam }: { pageParam: number }): Promise<TodosPage> {
|
||||
const limit = 20
|
||||
const start = pageParam * limit
|
||||
const end = start + limit
|
||||
|
||||
const response = await fetch(
|
||||
`https://jsonplaceholder.typicode.com/todos?_start=${start}&_limit=${limit}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch todos')
|
||||
}
|
||||
|
||||
const data: Todo[] = await response.json()
|
||||
|
||||
return {
|
||||
data,
|
||||
nextCursor: data.length === limit ? pageParam + 1 : null,
|
||||
previousCursor: pageParam > 0 ? pageParam - 1 : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infinite query hook
|
||||
*
|
||||
* Usage:
|
||||
* const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteTodos()
|
||||
*/
|
||||
export function useInfiniteTodos() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['todos', 'infinite'],
|
||||
queryFn: fetchTodosPage,
|
||||
|
||||
// v5 REQUIRES initialPageParam (was optional in v4)
|
||||
initialPageParam: 0,
|
||||
|
||||
// Determine if there are more pages
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
|
||||
// Optional: Determine if there are previous pages (bidirectional)
|
||||
getPreviousPageParam: (firstPage) => firstPage.previousCursor,
|
||||
|
||||
// How many pages to keep in memory (default: Infinity)
|
||||
maxPages: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Component with manual "Load More" button
|
||||
*/
|
||||
export function InfiniteTodosManual() {
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isPending,
|
||||
isError,
|
||||
error,
|
||||
} = useInfiniteTodos()
|
||||
|
||||
if (isPending) return <div>Loading...</div>
|
||||
if (isError) return <div>Error: {error.message}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Infinite Todos (Manual)</h1>
|
||||
|
||||
{/* Render all pages */}
|
||||
{data.pages.map((page, i) => (
|
||||
<div key={i}>
|
||||
<h2>Page {i + 1}</h2>
|
||||
<ul>
|
||||
{page.data.map((todo) => (
|
||||
<li key={todo.id}>
|
||||
<input type="checkbox" checked={todo.completed} readOnly />
|
||||
{todo.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Load more button */}
|
||||
<button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? 'Loading more...'
|
||||
: hasNextPage
|
||||
? 'Load More'
|
||||
: 'No more todos'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component with automatic infinite scroll
|
||||
* Uses Intersection Observer to detect when user scrolls to bottom
|
||||
*/
|
||||
export function InfiniteTodosAuto() {
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isPending,
|
||||
isError,
|
||||
error,
|
||||
} = useInfiniteTodos()
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Intersection Observer for automatic loading
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// When sentinel element is visible and there are more pages
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 } // Trigger when 10% of element is visible
|
||||
)
|
||||
|
||||
if (loadMoreRef.current) {
|
||||
observer.observe(loadMoreRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
|
||||
|
||||
if (isPending) return <div>Loading...</div>
|
||||
if (isError) return <div>Error: {error.message}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Infinite Todos (Auto)</h1>
|
||||
|
||||
{/* Render all pages */}
|
||||
{data.pages.map((page, i) => (
|
||||
<div key={i}>
|
||||
{page.data.map((todo) => (
|
||||
<div key={todo.id}>
|
||||
<input type="checkbox" checked={todo.completed} readOnly />
|
||||
{todo.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Sentinel element - triggers loading when scrolled into view */}
|
||||
<div ref={loadMoreRef}>
|
||||
{isFetchingNextPage ? (
|
||||
<div>Loading more...</div>
|
||||
) : hasNextPage ? (
|
||||
<div>Scroll to load more</div>
|
||||
) : (
|
||||
<div>No more todos</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Key concepts:
|
||||
*
|
||||
* 1. data.pages: Array of all fetched pages
|
||||
* 2. fetchNextPage(): Loads next page
|
||||
* 3. hasNextPage: Boolean if more pages available
|
||||
* 4. isFetchingNextPage: Loading state for next page
|
||||
* 5. initialPageParam: Starting cursor (REQUIRED in v5)
|
||||
* 6. getNextPageParam: Function returning next cursor or null
|
||||
*
|
||||
* Access all data:
|
||||
* const allTodos = data.pages.flatMap(page => page.data)
|
||||
*
|
||||
* Bidirectional scrolling:
|
||||
* - Add getPreviousPageParam
|
||||
* - Use fetchPreviousPage() and hasPreviousPage
|
||||
*
|
||||
* Performance:
|
||||
* - Use maxPages to limit memory (e.g., maxPages: 10)
|
||||
* - Old pages are garbage collected automatically
|
||||
*
|
||||
* Common patterns:
|
||||
* - Manual: Load More button
|
||||
* - Auto: Intersection Observer
|
||||
* - Virtualized: react-window or react-virtual for huge lists
|
||||
*/
|
||||
201
templates/use-mutation-basic.tsx
Normal file
201
templates/use-mutation-basic.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
// src/hooks/useTodoMutations.ts
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Todo } from './useTodos'
|
||||
|
||||
/**
|
||||
* Input types for mutations
|
||||
*/
|
||||
type AddTodoInput = {
|
||||
title: string
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
type UpdateTodoInput = {
|
||||
id: number
|
||||
title?: string
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* API functions
|
||||
*/
|
||||
async function addTodo(newTodo: AddTodoInput): Promise<Todo> {
|
||||
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...newTodo, userId: 1 }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to add todo: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function updateTodo({ id, ...updates }: UpdateTodoInput): Promise<Todo> {
|
||||
const response = await fetch(
|
||||
`https://jsonplaceholder.typicode.com/todos/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update todo: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function deleteTodo(id: number): Promise<void> {
|
||||
const response = await fetch(
|
||||
`https://jsonplaceholder.typicode.com/todos/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete todo: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Add new todo
|
||||
*
|
||||
* Usage:
|
||||
* const { mutate, isPending, isError, error } = useAddTodo()
|
||||
* mutate({ title: 'New todo' })
|
||||
*/
|
||||
export function useAddTodo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: addTodo,
|
||||
|
||||
// Runs on successful mutation
|
||||
onSuccess: () => {
|
||||
// Invalidate todos query to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
},
|
||||
|
||||
// Runs on error
|
||||
onError: (error) => {
|
||||
console.error('Failed to add todo:', error)
|
||||
// Add user notification here (toast, alert, etc.)
|
||||
},
|
||||
|
||||
// Runs regardless of success or error
|
||||
onSettled: () => {
|
||||
console.log('Add todo mutation completed')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Update existing todo
|
||||
*
|
||||
* Usage:
|
||||
* const { mutate } = useUpdateTodo()
|
||||
* mutate({ id: 1, completed: true })
|
||||
*/
|
||||
export function useUpdateTodo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateTodo,
|
||||
|
||||
onSuccess: (updatedTodo) => {
|
||||
// Update specific todo in cache
|
||||
queryClient.setQueryData<Todo>(['todos', updatedTodo.id], updatedTodo)
|
||||
|
||||
// Invalidate list to refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Delete todo
|
||||
*
|
||||
* Usage:
|
||||
* const { mutate } = useDeleteTodo()
|
||||
* mutate(todoId)
|
||||
*/
|
||||
export function useDeleteTodo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteTodo,
|
||||
|
||||
onSuccess: (_, deletedId) => {
|
||||
// Remove from list cache
|
||||
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
|
||||
old.filter((todo) => todo.id !== deletedId)
|
||||
)
|
||||
|
||||
// Remove individual todo cache
|
||||
queryClient.removeQueries({ queryKey: ['todos', deletedId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Component usage example:
|
||||
*/
|
||||
export function AddTodoForm() {
|
||||
const { mutate, isPending, isError, error } = useAddTodo()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const title = formData.get('title') as string
|
||||
|
||||
mutate(
|
||||
{ title },
|
||||
{
|
||||
// Optional per-mutation callbacks
|
||||
onSuccess: () => {
|
||||
e.currentTarget.reset()
|
||||
console.log('Todo added successfully!')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
placeholder="New todo..."
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Adding...' : 'Add Todo'}
|
||||
</button>
|
||||
{isError && <div>Error: {error.message}</div>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Key concepts:
|
||||
*
|
||||
* 1. Mutations don't cache data (unlike queries)
|
||||
* 2. Use onSuccess to invalidate related queries
|
||||
* 3. queryClient.invalidateQueries() marks queries as stale and refetches
|
||||
* 4. queryClient.setQueryData() directly updates cache (optimistic update)
|
||||
* 5. queryClient.removeQueries() removes specific query from cache
|
||||
*
|
||||
* Mutation states:
|
||||
* - isPending: Mutation in progress
|
||||
* - isError: Mutation failed
|
||||
* - isSuccess: Mutation succeeded
|
||||
* - data: Returned data from mutationFn
|
||||
* - error: Error if mutation failed
|
||||
*/
|
||||
234
templates/use-mutation-optimistic.tsx
Normal file
234
templates/use-mutation-optimistic.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
// src/hooks/useOptimisticTodoMutations.ts
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Todo } from './useTodos'
|
||||
|
||||
/**
|
||||
* Optimistic Update Pattern
|
||||
*
|
||||
* Updates UI immediately before server responds, then:
|
||||
* - On success: Keep the optimistic update
|
||||
* - On error: Roll back to previous state
|
||||
*
|
||||
* Best for:
|
||||
* - Low-risk actions (toggle, like, favorite)
|
||||
* - Frequently used actions (better UX with instant feedback)
|
||||
*
|
||||
* Avoid for:
|
||||
* - Critical operations (payments, account changes)
|
||||
* - Complex validations (server might reject)
|
||||
*/
|
||||
|
||||
type AddTodoInput = {
|
||||
title: string
|
||||
}
|
||||
|
||||
type UpdateTodoInput = {
|
||||
id: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic Add Todo
|
||||
*
|
||||
* Immediately shows new todo in UI, then confirms with server
|
||||
*/
|
||||
export function useOptimisticAddTodo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (newTodo: AddTodoInput) => {
|
||||
const response = await fetch(
|
||||
'https://jsonplaceholder.typicode.com/todos',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...newTodo, userId: 1, completed: false }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to add todo')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// Before mutation runs
|
||||
onMutate: async (newTodo) => {
|
||||
// Cancel outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||
|
||||
// Snapshot current value
|
||||
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
|
||||
|
||||
// Optimistically update cache
|
||||
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
|
||||
...old,
|
||||
{
|
||||
id: Date.now(), // Temporary ID
|
||||
...newTodo,
|
||||
completed: false,
|
||||
userId: 1,
|
||||
},
|
||||
])
|
||||
|
||||
// Return context with snapshot (used for rollback)
|
||||
return { previousTodos }
|
||||
},
|
||||
|
||||
// If mutation fails, rollback using context
|
||||
onError: (err, newTodo, context) => {
|
||||
console.error('Failed to add todo:', err)
|
||||
|
||||
// Restore previous state
|
||||
if (context?.previousTodos) {
|
||||
queryClient.setQueryData(['todos'], context.previousTodos)
|
||||
}
|
||||
},
|
||||
|
||||
// Always refetch after mutation settles (success or error)
|
||||
// Ensures cache matches server state
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic Update Todo
|
||||
*
|
||||
* Immediately toggles todo in UI, confirms with server
|
||||
*/
|
||||
export function useOptimisticUpdateTodo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, completed }: UpdateTodoInput) => {
|
||||
const response = await fetch(
|
||||
`https://jsonplaceholder.typicode.com/todos/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ completed }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update todo')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
onMutate: async ({ id, completed }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||
|
||||
// Snapshot
|
||||
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
|
||||
|
||||
// Optimistic update
|
||||
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
|
||||
old.map((todo) =>
|
||||
todo.id === id ? { ...todo, completed } : todo
|
||||
)
|
||||
)
|
||||
|
||||
return { previousTodos }
|
||||
},
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
console.error('Failed to update todo:', err)
|
||||
if (context?.previousTodos) {
|
||||
queryClient.setQueryData(['todos'], context.previousTodos)
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: (data, error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['todos', variables.id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic Delete Todo
|
||||
*
|
||||
* Immediately removes todo from UI, confirms with server
|
||||
*/
|
||||
export function useOptimisticDeleteTodo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await fetch(
|
||||
`https://jsonplaceholder.typicode.com/todos/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete todo')
|
||||
},
|
||||
|
||||
onMutate: async (deletedId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||
|
||||
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
|
||||
|
||||
// Optimistically remove from cache
|
||||
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
|
||||
old.filter((todo) => todo.id !== deletedId)
|
||||
)
|
||||
|
||||
return { previousTodos }
|
||||
},
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
console.error('Failed to delete todo:', err)
|
||||
if (context?.previousTodos) {
|
||||
queryClient.setQueryData(['todos'], context.previousTodos)
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Component usage example:
|
||||
*/
|
||||
export function OptimisticTodoItem({ todo }: { todo: Todo }) {
|
||||
const { mutate: updateTodo, isPending: isUpdating } = useOptimisticUpdateTodo()
|
||||
const { mutate: deleteTodo, isPending: isDeleting } = useOptimisticDeleteTodo()
|
||||
|
||||
return (
|
||||
<li style={{ opacity: isUpdating || isDeleting ? 0.5 : 1 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todo.completed}
|
||||
onChange={(e) => updateTodo({ id: todo.id, completed: e.target.checked })}
|
||||
disabled={isUpdating || isDeleting}
|
||||
/>
|
||||
<span>{todo.title}</span>
|
||||
<button
|
||||
onClick={() => deleteTodo(todo.id)}
|
||||
disabled={isUpdating || isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Key patterns:
|
||||
*
|
||||
* 1. onMutate: Cancel queries, snapshot state, update cache optimistically
|
||||
* 2. onError: Rollback using context
|
||||
* 3. onSettled: Refetch to ensure cache matches server (always runs)
|
||||
* 4. cancelQueries: Prevent race conditions
|
||||
* 5. Return context from onMutate: Available in onError and onSettled
|
||||
*
|
||||
* Trade-offs:
|
||||
* ✅ Instant UI feedback (feels faster)
|
||||
* ✅ Better UX for common actions
|
||||
* ❌ More complex code
|
||||
* ❌ Risk of inconsistent state if not handled correctly
|
||||
* ❌ Not suitable for critical operations
|
||||
*/
|
||||
119
templates/use-query-basic.tsx
Normal file
119
templates/use-query-basic.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// src/hooks/useTodos.ts
|
||||
import { useQuery, queryOptions } from '@tanstack/react-query'
|
||||
|
||||
/**
|
||||
* Type definitions
|
||||
*/
|
||||
export type Todo = {
|
||||
id: number
|
||||
title: string
|
||||
completed: boolean
|
||||
userId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* API function - keeps network logic separate
|
||||
*/
|
||||
async function fetchTodos(): Promise<Todo[]> {
|
||||
const response = await fetch('https://jsonplaceholder.typicode.com/todos')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch todos: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options factory (v5 best practice)
|
||||
*
|
||||
* Benefits:
|
||||
* - Reusable across useQuery, useSuspenseQuery, prefetchQuery
|
||||
* - Perfect type inference
|
||||
* - Single source of truth for queryKey and queryFn
|
||||
*/
|
||||
export const todosQueryOptions = queryOptions({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
})
|
||||
|
||||
/**
|
||||
* Custom hook - encapsulates query logic
|
||||
*
|
||||
* Usage in component:
|
||||
* const { data, isPending, isError, error } = useTodos()
|
||||
*/
|
||||
export function useTodos() {
|
||||
return useQuery(todosQueryOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch single todo by ID
|
||||
*/
|
||||
async function fetchTodoById(id: number): Promise<Todo> {
|
||||
const response = await fetch(
|
||||
`https://jsonplaceholder.typicode.com/todos/${id}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch todo ${id}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for fetching single todo
|
||||
*
|
||||
* Usage:
|
||||
* const { data: todo } = useTodo(1)
|
||||
*/
|
||||
export function useTodo(id: number) {
|
||||
return useQuery({
|
||||
queryKey: ['todos', id],
|
||||
queryFn: () => fetchTodoById(id),
|
||||
enabled: !!id, // Only fetch if id is truthy
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Component usage example:
|
||||
*/
|
||||
export function TodoList() {
|
||||
const { data, isPending, isError, error, isFetching } = useTodos()
|
||||
|
||||
if (isPending) {
|
||||
return <div>Loading todos...</div>
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Error: {error.message}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Todos {isFetching && '(Refetching...)'}</h1>
|
||||
<ul>
|
||||
{data.map((todo) => (
|
||||
<li key={todo.id}>
|
||||
<input type="checkbox" checked={todo.completed} readOnly />
|
||||
{todo.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Key states explained:
|
||||
*
|
||||
* - isPending: No data yet (initial fetch)
|
||||
* - isLoading: isPending && isFetching (loading for first time)
|
||||
* - isFetching: Any background fetch in progress
|
||||
* - isError: Query failed
|
||||
* - isSuccess: Query succeeded and data is available
|
||||
* - data: The fetched data (undefined while isPending)
|
||||
* - error: Error object if query failed
|
||||
*/
|
||||
Reference in New Issue
Block a user