@jgamaraalv/ts-dev-kit 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/tanstack-query/SKILL.md +348 -0
- package/skills/tanstack-query/references/advanced-patterns.md +376 -0
- package/skills/tanstack-query/references/api-reference.md +297 -0
- package/skills/tanstack-query/references/ssr-nextjs.md +272 -0
- package/skills/tanstack-query/references/testing.md +175 -0
package/package.json
CHANGED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tanstack-query
|
|
3
|
+
description: |
|
|
4
|
+
TanStack Query v5 (React Query) reference for data fetching, caching,
|
|
5
|
+
and server state management in React. Use when: (1) writing useQuery,
|
|
6
|
+
useMutation, or useInfiniteQuery hooks, (2) setting up QueryClient
|
|
7
|
+
and queryOptions, (3) implementing optimistic updates or cache
|
|
8
|
+
invalidation, (4) configuring SSR/hydration with Next.js App Router
|
|
9
|
+
or Pages Router, (5) testing React Query hooks, (6) working with
|
|
10
|
+
TypeScript types, Suspense, or advanced patterns like dependent
|
|
11
|
+
queries and infinite scroll.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# TanStack Query v5 (React)
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
20
|
+
|
|
21
|
+
const queryClient = new QueryClient({
|
|
22
|
+
defaultOptions: {
|
|
23
|
+
queries: {
|
|
24
|
+
staleTime: 60 * 1000, // 1 min (default is 0)
|
|
25
|
+
gcTime: 5 * 60 * 1000, // 5 min (default)
|
|
26
|
+
retry: 3, // 3 retries with exponential backoff (default)
|
|
27
|
+
refetchOnWindowFocus: true, // default
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function App() {
|
|
33
|
+
return (
|
|
34
|
+
<QueryClientProvider client={queryClient}>
|
|
35
|
+
<YourApp />
|
|
36
|
+
</QueryClientProvider>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Important Defaults
|
|
42
|
+
|
|
43
|
+
| Default | Value | Notes |
|
|
44
|
+
|---------|-------|-------|
|
|
45
|
+
| `staleTime` | `0` | Cached data is immediately stale; triggers background refetch on mount/focus/reconnect |
|
|
46
|
+
| `gcTime` | `5 min` | Inactive queries garbage collected after 5 minutes |
|
|
47
|
+
| `retry` | `3` (queries) / `0` (mutations) | Queries retry 3x with exponential backoff; mutations do NOT retry |
|
|
48
|
+
| `refetchOnWindowFocus` | `true` | Stale queries refetch when tab regains focus |
|
|
49
|
+
| `refetchOnReconnect` | `true` | Stale queries refetch when network reconnects |
|
|
50
|
+
| `refetchOnMount` | `true` | Stale queries refetch when new instance mounts |
|
|
51
|
+
| `structuralSharing` | `true` | Preserves referential identity if data is structurally equal |
|
|
52
|
+
|
|
53
|
+
**Key recommendation:** Set `staleTime` above 0 to control refetch frequency rather than disabling individual refetch triggers.
|
|
54
|
+
|
|
55
|
+
## queryOptions — co-locate key + fn
|
|
56
|
+
|
|
57
|
+
Always use `queryOptions` to define query configurations. It enables type inference across `useQuery`, `prefetchQuery`, `getQueryData`, and `setQueryData`.
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query'
|
|
61
|
+
|
|
62
|
+
export function todosOptions(filters: TodoFilters) {
|
|
63
|
+
return queryOptions({
|
|
64
|
+
queryKey: ['todos', filters],
|
|
65
|
+
queryFn: () => fetchTodos(filters),
|
|
66
|
+
staleTime: 5 * 1000,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Works everywhere with full type inference:
|
|
71
|
+
useQuery(todosOptions({ status: 'done' }))
|
|
72
|
+
useSuspenseQuery(todosOptions({ status: 'done' }))
|
|
73
|
+
queryClient.prefetchQuery(todosOptions({ status: 'done' }))
|
|
74
|
+
queryClient.setQueryData(todosOptions({ status: 'done' }).queryKey, newData)
|
|
75
|
+
const cached = queryClient.getQueryData(todosOptions({ status: 'done' }).queryKey)
|
|
76
|
+
// ^? TodoItem[] | undefined
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
For infinite queries, use `infiniteQueryOptions` (same pattern, adds `initialPageParam` and `getNextPageParam`).
|
|
80
|
+
|
|
81
|
+
## useQuery
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
const {
|
|
85
|
+
data, // TData | undefined
|
|
86
|
+
error, // TError | null
|
|
87
|
+
status, // 'pending' | 'error' | 'success'
|
|
88
|
+
isPending, // no cached data yet
|
|
89
|
+
isError,
|
|
90
|
+
isSuccess,
|
|
91
|
+
isFetching, // queryFn is running (including background refetch)
|
|
92
|
+
isLoading, // isPending && isFetching (first load only)
|
|
93
|
+
isPlaceholderData, // showing placeholder, not real data
|
|
94
|
+
isStale,
|
|
95
|
+
refetch,
|
|
96
|
+
fetchStatus, // 'fetching' | 'paused' | 'idle'
|
|
97
|
+
} = useQuery({
|
|
98
|
+
queryKey: ['todos', userId], // unique cache key (Array)
|
|
99
|
+
queryFn: () => fetchTodos(userId),
|
|
100
|
+
enabled: !!userId, // disable until userId exists
|
|
101
|
+
staleTime: 60_000,
|
|
102
|
+
select: (data) => data.filter(t => !t.done), // transform/filter
|
|
103
|
+
placeholderData: keepPreviousData, // smooth pagination
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Query states:** `status` tells you "do we have data?"; `fetchStatus` tells you "is the queryFn running?". They are orthogonal — a query can be `pending` + `paused` (no data, no network).
|
|
108
|
+
|
|
109
|
+
## Query Keys
|
|
110
|
+
|
|
111
|
+
Keys must be Arrays. They are hashed deterministically.
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
// Object key order does NOT matter — these are equivalent:
|
|
115
|
+
useQuery({ queryKey: ['todos', { status, page }] })
|
|
116
|
+
useQuery({ queryKey: ['todos', { page, status }] })
|
|
117
|
+
|
|
118
|
+
// Array item order DOES matter — these are different:
|
|
119
|
+
useQuery({ queryKey: ['todos', status, page] })
|
|
120
|
+
useQuery({ queryKey: ['todos', page, status] })
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Rule:** If your queryFn depends on a variable, include it in the queryKey. The key acts as a dependency array.
|
|
124
|
+
|
|
125
|
+
## useMutation
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
const queryClient = useQueryClient()
|
|
129
|
+
|
|
130
|
+
const mutation = useMutation({
|
|
131
|
+
mutationFn: (newTodo: CreateTodoInput) => api.post('/todos', newTodo),
|
|
132
|
+
onSuccess: (data, variables) => {
|
|
133
|
+
// Invalidate related queries to trigger refetch
|
|
134
|
+
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
|
135
|
+
// Or update cache directly with response data
|
|
136
|
+
queryClient.setQueryData(['todos', data.id], data)
|
|
137
|
+
},
|
|
138
|
+
onError: (error, variables, onMutateResult) => {},
|
|
139
|
+
onSettled: (data, error, variables, onMutateResult) => {},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Trigger:
|
|
143
|
+
mutation.mutate({ title: 'New todo' })
|
|
144
|
+
|
|
145
|
+
// Or with per-call callbacks:
|
|
146
|
+
mutation.mutate(input, { onSuccess: () => navigate('/todos') })
|
|
147
|
+
|
|
148
|
+
// Async variant (returns Promise):
|
|
149
|
+
const data = await mutation.mutateAsync(input)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Lifecycle:** `onMutate` → `mutationFn` → `onSuccess`/`onError` → `onSettled`. Callbacks returning promises are awaited.
|
|
153
|
+
|
|
154
|
+
**Gotcha:** Per-call `mutate()` callbacks only fire for the *latest* call if mutations overlap. Use `useMutation`-level callbacks for reliable logic.
|
|
155
|
+
|
|
156
|
+
## Query Invalidation
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
// Prefix match (default) — invalidates ['todos'] and ['todos', { page: 1 }]
|
|
160
|
+
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
|
161
|
+
|
|
162
|
+
// Exact match only
|
|
163
|
+
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })
|
|
164
|
+
|
|
165
|
+
// Predicate for fine-grained control
|
|
166
|
+
queryClient.invalidateQueries({
|
|
167
|
+
predicate: (query) => query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ALL queries
|
|
171
|
+
queryClient.invalidateQueries()
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Invalidation marks queries as stale and triggers background refetch for active (rendered) queries.
|
|
175
|
+
|
|
176
|
+
## Optimistic Updates
|
|
177
|
+
|
|
178
|
+
### Approach 1: Via the UI (simpler, recommended)
|
|
179
|
+
|
|
180
|
+
Render optimistic state from `variables` directly in JSX:
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
const { mutate, variables, isPending, isError } = useMutation({
|
|
184
|
+
mutationFn: (text: string) => api.post('/todos', { text }),
|
|
185
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// In JSX:
|
|
189
|
+
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
|
|
190
|
+
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Access pending mutations from other components with `useMutationState`:
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
const pendingTodos = useMutationState<string>({
|
|
197
|
+
filters: { mutationKey: ['addTodo'], status: 'pending' },
|
|
198
|
+
select: (mutation) => mutation.state.variables,
|
|
199
|
+
})
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Approach 2: Via cache (with rollback)
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
useMutation({
|
|
206
|
+
mutationFn: updateTodo,
|
|
207
|
+
onMutate: async (newTodo, context) => {
|
|
208
|
+
await context.client.cancelQueries({ queryKey: ['todos'] })
|
|
209
|
+
const previous = context.client.getQueryData(['todos'])
|
|
210
|
+
context.client.setQueryData(['todos'], (old) => [...old, newTodo])
|
|
211
|
+
return { previous }
|
|
212
|
+
},
|
|
213
|
+
onError: (err, newTodo, onMutateResult, context) => {
|
|
214
|
+
context.client.setQueryData(['todos'], onMutateResult.previous)
|
|
215
|
+
},
|
|
216
|
+
onSettled: (data, error, variables, onMutateResult, context) => {
|
|
217
|
+
context.client.invalidateQueries({ queryKey: ['todos'] })
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Always** `cancelQueries` before optimistic update to prevent background refetches from overwriting.
|
|
223
|
+
|
|
224
|
+
## Infinite Queries
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
const {
|
|
228
|
+
data, // { pages: T[], pageParams: unknown[] }
|
|
229
|
+
fetchNextPage,
|
|
230
|
+
fetchPreviousPage,
|
|
231
|
+
hasNextPage, // true when getNextPageParam returns non-null/undefined
|
|
232
|
+
hasPreviousPage,
|
|
233
|
+
isFetchingNextPage,
|
|
234
|
+
isFetchingPreviousPage,
|
|
235
|
+
} = useInfiniteQuery({
|
|
236
|
+
queryKey: ['projects'],
|
|
237
|
+
queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
|
238
|
+
initialPageParam: 0, // REQUIRED
|
|
239
|
+
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor ?? undefined,
|
|
240
|
+
maxPages: 3, // optional: cap cached pages
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// Render all pages:
|
|
244
|
+
{data.pages.map((page, i) => (
|
|
245
|
+
<Fragment key={i}>
|
|
246
|
+
{page.items.map(item => <div key={item.id}>{item.name}</div>)}
|
|
247
|
+
</Fragment>
|
|
248
|
+
))}
|
|
249
|
+
|
|
250
|
+
// Load more:
|
|
251
|
+
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
|
|
252
|
+
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more'}
|
|
253
|
+
</button>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Gotcha:** `data` is `{ pages, pageParams }`, not flat data. `initialData` and `placeholderData` must match this shape.
|
|
257
|
+
|
|
258
|
+
## Paginated Queries (keep previous data)
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
|
262
|
+
|
|
263
|
+
const { data, isPlaceholderData } = useQuery({
|
|
264
|
+
queryKey: ['projects', page],
|
|
265
|
+
queryFn: () => fetchProjects(page),
|
|
266
|
+
placeholderData: keepPreviousData,
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// Prefetch next page for instant transitions:
|
|
270
|
+
queryClient.prefetchQuery({
|
|
271
|
+
queryKey: ['projects', page + 1],
|
|
272
|
+
queryFn: () => fetchProjects(page + 1),
|
|
273
|
+
})
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Prefetching
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
// In event handlers (hover/focus):
|
|
280
|
+
const prefetch = () => queryClient.prefetchQuery(todosOptions())
|
|
281
|
+
<button onMouseEnter={prefetch} onFocus={prefetch} onClick={handleClick}>Show</button>
|
|
282
|
+
|
|
283
|
+
// In components (avoid Suspense waterfalls):
|
|
284
|
+
function Layout({ id }: { id: string }) {
|
|
285
|
+
usePrefetchQuery(commentsOptions(id)) // starts fetch immediately
|
|
286
|
+
return (
|
|
287
|
+
<Suspense fallback="Loading...">
|
|
288
|
+
<Article id={id} />
|
|
289
|
+
</Suspense>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Prefetch infinite queries:
|
|
294
|
+
queryClient.prefetchInfiniteQuery({
|
|
295
|
+
...projectsInfiniteOptions(),
|
|
296
|
+
pages: 3, // prefetch first 3 pages
|
|
297
|
+
})
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Dependent Queries
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
const { data: user } = useQuery({
|
|
304
|
+
queryKey: ['user', email],
|
|
305
|
+
queryFn: () => getUserByEmail(email),
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
const { data: projects } = useQuery({
|
|
309
|
+
queryKey: ['projects', user?.id],
|
|
310
|
+
queryFn: () => getProjectsByUser(user!.id),
|
|
311
|
+
enabled: !!user?.id, // waits for user query
|
|
312
|
+
})
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**Type-safe disabling with skipToken:**
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
import { skipToken } from '@tanstack/react-query'
|
|
319
|
+
|
|
320
|
+
const { data } = useQuery({
|
|
321
|
+
queryKey: ['projects', userId],
|
|
322
|
+
queryFn: userId ? () => getProjects(userId) : skipToken,
|
|
323
|
+
})
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`skipToken` prevents `refetch()` from working — use `enabled: false` if you need manual refetch.
|
|
327
|
+
|
|
328
|
+
## setQueryData — immutability
|
|
329
|
+
|
|
330
|
+
```tsx
|
|
331
|
+
// WRONG — mutating cache in place
|
|
332
|
+
queryClient.setQueryData(['todo', id], (old) => {
|
|
333
|
+
if (old) old.title = 'new' // DO NOT DO THIS
|
|
334
|
+
return old
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// CORRECT — return new object
|
|
338
|
+
queryClient.setQueryData(['todo', id], (old) =>
|
|
339
|
+
old ? { ...old, title: 'new' } : old
|
|
340
|
+
)
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Further Reference
|
|
344
|
+
|
|
345
|
+
- **Full API signatures** (useQuery, useMutation, useInfiniteQuery, QueryClient): See [references/api-reference.md](references/api-reference.md)
|
|
346
|
+
- **SSR & Next.js** (hydration, App Router, streaming): See [references/ssr-nextjs.md](references/ssr-nextjs.md)
|
|
347
|
+
- **Testing** (renderHook, mocking, setup): See [references/testing.md](references/testing.md)
|
|
348
|
+
- **Advanced patterns** (TypeScript, Suspense, waterfalls, network modes): See [references/advanced-patterns.md](references/advanced-patterns.md)
|