Initial commit

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

View File

@@ -0,0 +1,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
View 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.

1585
SKILL.md Normal file

File diff suppressed because it is too large Load Diff

View 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
View 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": []
}
}

View File

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

View File

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

View File

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

282
references/testing.md Normal file
View File

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

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

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

View File

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

View File

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

15
scripts/example-script.sh Executable file
View 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]

View 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
*/

View 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
*/

View 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
View 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"
}
}

View 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' }
* }}
* />
*/

View 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
*/

View 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
*/

View 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
*/

View 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
*/

View 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
*/