Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "tanstack-query",
|
||||||
|
"description": "Skill: Master TanStack Query (React Query) v5 for server state management in React applications",
|
||||||
|
"version": "0.0.0-2025.11.28",
|
||||||
|
"author": {
|
||||||
|
"name": "Misha Kolesnik",
|
||||||
|
"email": "misha@kolesnik.io"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./skills/skill"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# tanstack-query
|
||||||
|
|
||||||
|
Skill: Master TanStack Query (React Query) v5 for server state management in React applications
|
||||||
64
plugin.lock.json
Normal file
64
plugin.lock.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:tenequm/claude-plugins:tanstack-query",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "04aed50c44909f730e8190b8861ff0cebfb3b949",
|
||||||
|
"treeHash": "180cde8bee0c8adac709ce89a26acf9494a4595bbaf89afc15767d7759e45395",
|
||||||
|
"generatedAt": "2025-11-28T10:28:38.693601Z",
|
||||||
|
"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": "Skill: Master TanStack Query (React Query) v5 for server state management in React applications"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "0e297fd0f1e8a629e69492f8a5636d1b1de283c1e2a0f66b6a0db72e74be857e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "21822bc4e4de797cb186a254c6ab86b7681af57c84acdd4968ea65bd06f5cb13"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/skill/SKILL.md",
|
||||||
|
"sha256": "5f31147fc01d688a4931e89219cd058e47c011c29ef112baea156c5f000d9f1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/skill/references/query-invalidation.md",
|
||||||
|
"sha256": "d8f71b95607b788324fc962e2662c5f3e1c49d644b18cedfc62a952468e4879a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/skill/references/performance.md",
|
||||||
|
"sha256": "65147137de2d91add1d16232d2a1ffb09e5e92a3f971bcb40b4e64a43ede8733"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/skill/references/optimistic-updates.md",
|
||||||
|
"sha256": "f34da30437a3ca7b31bb7c08f0d2612b80208bdb8865f4eee2440a1fcc2d7b1c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/skill/references/infinite-queries.md",
|
||||||
|
"sha256": "94b47a327c712e443a9cd8ae2668618933391f42fa1a83ed5faea70bf66d2bab"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/skill/references/typescript.md",
|
||||||
|
"sha256": "6b356bf3e779a074d019493d89a791cde4d1fac61f6f5020a75ca87ecd7f73b1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "180cde8bee0c8adac709ce89a26acf9494a4595bbaf89afc15767d7759e45395"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
612
skills/skill/SKILL.md
Normal file
612
skills/skill/SKILL.md
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
---
|
||||||
|
name: tanstack-query
|
||||||
|
description: Master TanStack Query (React Query) v5 for server state management in React applications. Use when fetching data from APIs, managing server state, caching, or handling mutations. Triggers on phrases like "react query", "tanstack query", "data fetching", "cache management", "server state", or file patterns like *query*.ts, *Query*.tsx, queryClient.ts.
|
||||||
|
---
|
||||||
|
|
||||||
|
# TanStack Query (React Query) v5
|
||||||
|
|
||||||
|
Powerful asynchronous state management for React. TanStack Query makes fetching, caching, synchronizing, and updating server state in your React applications a breeze.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
- Fetching data from REST APIs or GraphQL endpoints
|
||||||
|
- Managing server state and cache lifecycle
|
||||||
|
- Implementing mutations (create, update, delete operations)
|
||||||
|
- Building infinite scroll or load-more patterns
|
||||||
|
- Handling optimistic UI updates
|
||||||
|
- Synchronizing data across components
|
||||||
|
- Implementing background data refetching
|
||||||
|
- Managing complex async state without Redux or other state managers
|
||||||
|
|
||||||
|
## Quick Start Workflow
|
||||||
|
|
||||||
|
### 1. Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @tanstack/react-query
|
||||||
|
# or
|
||||||
|
pnpm add @tanstack/react-query
|
||||||
|
# or
|
||||||
|
yarn add @tanstack/react-query
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Setup QueryClient
|
||||||
|
|
||||||
|
Wrap your application with `QueryClientProvider`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<YourApp />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Basic Query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
function TodoList() {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch('https://api.example.com/todos');
|
||||||
|
if (!res.ok) throw new Error('Network response was not ok');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading...</div>;
|
||||||
|
if (error) return <div>Error: {error.message}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{data.map((todo) => (
|
||||||
|
<li key={todo.id}>{todo.title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Basic Mutation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
function CreateTodo() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (newTodo) => {
|
||||||
|
const res = await fetch('https://api.example.com/todos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(newTodo),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate and refetch todos
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={() => mutation.mutate({ title: 'New Todo' })}>
|
||||||
|
{mutation.isPending ? 'Creating...' : 'Create Todo'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### Query Keys
|
||||||
|
|
||||||
|
Query keys uniquely identify queries and are used for caching. They must be arrays.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Simple key
|
||||||
|
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
|
||||||
|
|
||||||
|
// Key with variables
|
||||||
|
useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) });
|
||||||
|
|
||||||
|
// Hierarchical keys
|
||||||
|
useQuery({ queryKey: ['todos', 'list', { filters, page }], queryFn: fetchTodos });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query key matching:**
|
||||||
|
- `['todos']` - exact match
|
||||||
|
- `['todos', { page: 1 }]` - exact match with object
|
||||||
|
- `{ queryKey: ['todos'] }` - matches all queries starting with 'todos'
|
||||||
|
|
||||||
|
### Query Functions
|
||||||
|
|
||||||
|
Query functions must return a promise that resolves data or throws an error:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using fetch
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using axios
|
||||||
|
queryFn: () => axios.get(url).then(res => res.data)
|
||||||
|
|
||||||
|
// With query key access
|
||||||
|
queryFn: ({ queryKey }) => {
|
||||||
|
const [_, todoId] = queryKey;
|
||||||
|
return fetchTodo(todoId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important Defaults
|
||||||
|
|
||||||
|
Understanding defaults is crucial for optimal usage:
|
||||||
|
|
||||||
|
- **staleTime: 0** - Queries become stale immediately by default
|
||||||
|
- **gcTime: 5 minutes** - Unused/inactive cache data remains in memory for 5 minutes
|
||||||
|
- **retry: 3** - Failed queries retry 3 times with exponential backoff
|
||||||
|
- **refetchOnWindowFocus: true** - Queries refetch when window regains focus
|
||||||
|
- **refetchOnReconnect: true** - Queries refetch when network reconnects
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Override defaults globally
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
gcTime: 1000 * 60 * 10, // 10 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or per query
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
staleTime: 1000 * 60, // 1 minute
|
||||||
|
retry: 5,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Status and Fetch Status
|
||||||
|
|
||||||
|
Queries have two important states:
|
||||||
|
|
||||||
|
**Query Status:**
|
||||||
|
- `pending` - No cached data, query is executing
|
||||||
|
- `error` - Query encountered an error
|
||||||
|
- `success` - Query succeeded and data is available
|
||||||
|
|
||||||
|
**Fetch Status:**
|
||||||
|
- `fetching` - Query function is executing
|
||||||
|
- `paused` - Query wants to fetch but is paused (offline)
|
||||||
|
- `idle` - Query is not fetching
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data, status, fetchStatus, isLoading, isFetching } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
});
|
||||||
|
|
||||||
|
// isLoading = status === 'pending'
|
||||||
|
// isFetching = fetchStatus === 'fetching'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Invalidation
|
||||||
|
|
||||||
|
Mark queries as stale to trigger refetches:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Invalidate all todos queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
|
||||||
|
// Invalidate specific query
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
|
||||||
|
|
||||||
|
// Invalidate and refetch immediately
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
refetchType: 'active' // only refetch active queries
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutations
|
||||||
|
|
||||||
|
Mutations are used for creating, updating, or deleting data:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (newTodo) => {
|
||||||
|
return fetch('/api/todos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(newTodo),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data, variables, context) => {
|
||||||
|
console.log('Success!', data);
|
||||||
|
},
|
||||||
|
onError: (error, variables, context) => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
},
|
||||||
|
onSettled: (data, error, variables, context) => {
|
||||||
|
console.log('Mutation finished');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger mutation
|
||||||
|
mutation.mutate({ title: 'New Todo' });
|
||||||
|
|
||||||
|
// With async/await
|
||||||
|
mutation.mutateAsync({ title: 'New Todo' })
|
||||||
|
.then(data => console.log(data))
|
||||||
|
.catch(error => console.error(error));
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Suspense Integration
|
||||||
|
|
||||||
|
TanStack Query supports React Suspense with dedicated hooks:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
function TodoList() {
|
||||||
|
// This will suspend the component until data is ready
|
||||||
|
const { data } = useSuspenseQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No need for loading states - handled by Suspense boundary
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{data.map((todo) => (
|
||||||
|
<li key={todo.id}>{todo.title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In parent component
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading todos...</div>}>
|
||||||
|
<TodoList />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Topics
|
||||||
|
|
||||||
|
For detailed information on advanced patterns, see the reference files:
|
||||||
|
|
||||||
|
### Infinite Queries
|
||||||
|
|
||||||
|
For implementing infinite scroll and load-more patterns:
|
||||||
|
- See `references/infinite-queries.md` for comprehensive guide
|
||||||
|
- Covers `useInfiniteQuery` hook
|
||||||
|
- Bidirectional pagination
|
||||||
|
- `getNextPageParam` and `getPreviousPageParam`
|
||||||
|
- Refetching and background updates
|
||||||
|
|
||||||
|
### Optimistic Updates
|
||||||
|
|
||||||
|
For updating UI before server confirmation:
|
||||||
|
- See `references/optimistic-updates.md` for detailed patterns
|
||||||
|
- Optimistic mutations
|
||||||
|
- Rollback on error
|
||||||
|
- Context for cancellation
|
||||||
|
- UI feedback strategies
|
||||||
|
|
||||||
|
### TypeScript Support
|
||||||
|
|
||||||
|
For full type safety and inference:
|
||||||
|
- See `references/typescript.md` for complete TypeScript guide
|
||||||
|
- Type inference from query functions
|
||||||
|
- Generic type parameters
|
||||||
|
- Typing query options
|
||||||
|
- Custom hooks with types
|
||||||
|
- Error type narrowing
|
||||||
|
|
||||||
|
### Query Invalidation Patterns
|
||||||
|
|
||||||
|
For advanced cache invalidation strategies:
|
||||||
|
- See `references/query-invalidation.md`
|
||||||
|
- Partial matching
|
||||||
|
- Predicate functions
|
||||||
|
- Refetch strategies
|
||||||
|
- Query filters
|
||||||
|
|
||||||
|
### Performance Optimization
|
||||||
|
|
||||||
|
For optimizing query performance:
|
||||||
|
- See `references/performance.md`
|
||||||
|
- Query deduplication
|
||||||
|
- Structural sharing
|
||||||
|
- Memory management
|
||||||
|
- Query splitting strategies
|
||||||
|
|
||||||
|
## DevTools
|
||||||
|
|
||||||
|
TanStack Query DevTools provide visual insights into query state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @tanstack/react-query-devtools
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<YourApp />
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DevTools features:**
|
||||||
|
- View all queries and their states
|
||||||
|
- Inspect query data and errors
|
||||||
|
- Manually trigger refetches
|
||||||
|
- Invalidate queries
|
||||||
|
- Monitor cache lifecycle
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Dependent Queries
|
||||||
|
|
||||||
|
Run queries in sequence when one depends on another:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// First query
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['user', userId],
|
||||||
|
queryFn: () => fetchUser(userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second query depends on first
|
||||||
|
const { data: projects } = useQuery({
|
||||||
|
queryKey: ['projects', user?.id],
|
||||||
|
queryFn: () => fetchProjects(user.id),
|
||||||
|
enabled: !!user?.id, // Only run when user.id is available
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parallel Queries
|
||||||
|
|
||||||
|
Multiple independent queries in one component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Dashboard() {
|
||||||
|
const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
|
||||||
|
const posts = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
|
||||||
|
const stats = useQuery({ queryKey: ['stats'], queryFn: fetchStats });
|
||||||
|
|
||||||
|
if (users.isLoading || posts.isLoading || stats.isLoading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All queries succeeded
|
||||||
|
return <DashboardView users={users.data} posts={posts.data} stats={stats.data} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Parallel Queries
|
||||||
|
|
||||||
|
Use `useQueries` for dynamic number of queries:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useQueries } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
function TodoLists({ listIds }) {
|
||||||
|
const results = useQueries({
|
||||||
|
queries: listIds.map((id) => ({
|
||||||
|
queryKey: ['list', id],
|
||||||
|
queryFn: () => fetchList(id),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = results.some(result => result.isLoading);
|
||||||
|
const data = results.map(result => result.data);
|
||||||
|
|
||||||
|
return <Lists data={data} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prefetching
|
||||||
|
|
||||||
|
Prefetch data before it's needed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Prefetch on hover
|
||||||
|
function TodoListLink({ id }) {
|
||||||
|
const prefetch = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ['todo', id],
|
||||||
|
queryFn: () => fetchTodo(id),
|
||||||
|
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={`/todo/${id}`} onMouseEnter={prefetch}>
|
||||||
|
View Todo
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Initial Data
|
||||||
|
|
||||||
|
Provide initial data to avoid loading states:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TodoDetail({ todoId, initialTodo }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todo', todoId],
|
||||||
|
queryFn: () => fetchTodo(todoId),
|
||||||
|
initialData: initialTodo, // Use this data immediately
|
||||||
|
staleTime: 1000 * 60, // Consider fresh for 1 minute
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>{data.title}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Placeholder Data
|
||||||
|
|
||||||
|
Show placeholder while loading:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data, isPlaceholderData } = useQuery({
|
||||||
|
queryKey: ['todos', page],
|
||||||
|
queryFn: () => fetchTodos(page),
|
||||||
|
placeholderData: (previousData) => previousData, // Keep previous data while loading
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or use static placeholder
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
placeholderData: { items: [], total: 0 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Query Errors
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { error, isError } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
retry: 3,
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <div>Error: {error.message}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Error Handling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Query error:', error);
|
||||||
|
// Show toast notification, etc.
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Mutation error:', error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Boundaries
|
||||||
|
|
||||||
|
Combine with React Error Boundaries:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
|
|
||||||
|
function TodoList() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
throwOnError: true, // Throw errors to error boundary
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>{/* render data */}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
||||||
|
<TodoList />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use Query Keys Wisely**
|
||||||
|
- Structure keys hierarchically: `['todos', 'list', { filters }]`
|
||||||
|
- Include all variables in the key
|
||||||
|
- Keep keys consistent across your app
|
||||||
|
|
||||||
|
2. **Set Appropriate staleTime**
|
||||||
|
- Static data: `staleTime: Infinity`
|
||||||
|
- Frequently changing: `staleTime: 0` (default)
|
||||||
|
- Moderately changing: `staleTime: 1000 * 60 * 5` (5 minutes)
|
||||||
|
|
||||||
|
3. **Handle Loading and Error States**
|
||||||
|
- Always check `isLoading` and `error`
|
||||||
|
- Provide meaningful loading indicators
|
||||||
|
- Show user-friendly error messages
|
||||||
|
|
||||||
|
4. **Optimize Refetching**
|
||||||
|
- Disable unnecessary refetches with `refetchOnWindowFocus: false`
|
||||||
|
- Use `staleTime` to reduce refetches
|
||||||
|
- Consider using `refetchInterval` for polling
|
||||||
|
|
||||||
|
5. **Invalidate Efficiently**
|
||||||
|
- Invalidate specific queries, not all queries
|
||||||
|
- Use query key prefixes for related queries
|
||||||
|
- Combine with optimistic updates for better UX
|
||||||
|
|
||||||
|
6. **Use TypeScript**
|
||||||
|
- Type your query functions for type inference
|
||||||
|
- Use generic type parameters when needed
|
||||||
|
- Enable strict type checking
|
||||||
|
|
||||||
|
7. **Leverage DevTools**
|
||||||
|
- Install DevTools in development
|
||||||
|
- Monitor query behavior
|
||||||
|
- Debug cache issues
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **Official Documentation**: https://tanstack.com/query/latest/docs/framework/react/overview
|
||||||
|
- **GitHub Repository**: https://github.com/TanStack/query
|
||||||
|
- **Examples**: https://tanstack.com/query/latest/docs/framework/react/examples
|
||||||
|
- **Community**: https://discord.gg/tanstack
|
||||||
|
- **TypeScript Guide**: https://tanstack.com/query/latest/docs/framework/react/typescript
|
||||||
|
|
||||||
|
## Migration from v4
|
||||||
|
|
||||||
|
If you're upgrading from React Query v4:
|
||||||
|
|
||||||
|
- `cacheTime` renamed to `gcTime`
|
||||||
|
- `useInfiniteQuery` pageParam changes
|
||||||
|
- New `useSuspenseQuery` hooks
|
||||||
|
- Improved TypeScript inference
|
||||||
|
- See official migration guide: https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5
|
||||||
653
skills/skill/references/infinite-queries.md
Normal file
653
skills/skill/references/infinite-queries.md
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
# Infinite Queries
|
||||||
|
|
||||||
|
Infinite queries are used for implementing "load more" and infinite scroll patterns. They allow you to fetch paginated data progressively.
|
||||||
|
|
||||||
|
## Basic Infinite Query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
function Posts() {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isLoading,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const res = await fetch(`/api/posts?cursor=${pageParam}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
// Return undefined if no more pages
|
||||||
|
return lastPage.nextCursor ?? undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data.pages.map((page, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{page.posts.map((post) => (
|
||||||
|
<div key={post.id}>{post.title}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={!hasNextPage || isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage
|
||||||
|
? 'Loading more...'
|
||||||
|
: hasNextPage
|
||||||
|
? 'Load More'
|
||||||
|
: 'Nothing more to load'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Structure
|
||||||
|
|
||||||
|
The `data` object has a specific structure:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
pages: [
|
||||||
|
{ posts: [...], nextCursor: 1 }, // Page 1
|
||||||
|
{ posts: [...], nextCursor: 2 }, // Page 2
|
||||||
|
{ posts: [...], nextCursor: 3 }, // Page 3
|
||||||
|
],
|
||||||
|
pageParams: [0, 1, 2] // The pageParam values used
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Page Parameters
|
||||||
|
|
||||||
|
### initialPageParam
|
||||||
|
|
||||||
|
Required parameter that specifies the initial page parameter:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: ({ pageParam }) => fetchPosts(pageParam),
|
||||||
|
initialPageParam: 0, // or 1, or { cursor: null }, etc.
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### getNextPageParam
|
||||||
|
|
||||||
|
Function that receives the last page and all pages, and returns the next page parameter:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Cursor-based pagination
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
return lastPage.nextCursor ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offset-based pagination
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
if (lastPage.length === 0) return undefined;
|
||||||
|
return allPages.length * 10; // Assuming 10 items per page
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page number pagination
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
const totalPages = lastPage.totalPages;
|
||||||
|
const nextPage = allPages.length + 1;
|
||||||
|
return nextPage <= totalPages ? nextPage : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access to all pages
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
const totalFetched = allPages.reduce((acc, page) => acc + page.data.length, 0);
|
||||||
|
return totalFetched < lastPage.total ? lastPage.nextCursor : undefined;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### getPreviousPageParam
|
||||||
|
|
||||||
|
For bidirectional infinite scrolling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: ({ pageParam }) => fetchPosts(pageParam),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
getPreviousPageParam: (firstPage) => firstPage.previousCursor,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fetching Pages
|
||||||
|
|
||||||
|
### Fetch Next Page
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
||||||
|
// ...config
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual trigger
|
||||||
|
<button onClick={() => fetchNextPage()} disabled={!hasNextPage}>
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Infinite scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (
|
||||||
|
window.innerHeight + window.scrollY >= document.body.offsetHeight - 500 &&
|
||||||
|
hasNextPage &&
|
||||||
|
!isFetchingNextPage
|
||||||
|
) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch Previous Page
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { fetchPreviousPage, hasPreviousPage, isFetchingPreviousPage } = useInfiniteQuery({
|
||||||
|
// ...config
|
||||||
|
});
|
||||||
|
|
||||||
|
<button onClick={() => fetchPreviousPage()} disabled={!hasPreviousPage}>
|
||||||
|
Load Previous
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pagination Strategies
|
||||||
|
|
||||||
|
### Cursor-Based Pagination
|
||||||
|
|
||||||
|
Best for real-time data and when items can be inserted/deleted:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const res = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
initialPageParam: null,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// API response structure:
|
||||||
|
// {
|
||||||
|
// data: [...],
|
||||||
|
// nextCursor: 'cursor_string' | null
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Offset-Based Pagination
|
||||||
|
|
||||||
|
Simpler but can have issues with real-time data:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const res = await fetch(`/api/posts?offset=${pageParam}&limit=10`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
if (lastPage.data.length < 10) return undefined;
|
||||||
|
return allPages.length * 10;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Number Pagination
|
||||||
|
|
||||||
|
Traditional page-based approach:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: async ({ pageParam = 1 }) => {
|
||||||
|
const res = await fetch(`/api/posts?page=${pageParam}&size=10`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
const currentPage = allPages.length;
|
||||||
|
return currentPage < lastPage.totalPages ? currentPage + 1 : undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Infinite Scroll Implementation
|
||||||
|
|
||||||
|
### Using Intersection Observer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
function InfiniteScrollPosts() {
|
||||||
|
const observerTarget = useRef(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup intersection observer
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 1.0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (observerTarget.current) {
|
||||||
|
observer.observe(observerTarget.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data?.pages.map((page, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{page.posts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<div ref={observerTarget} className="loading-indicator">
|
||||||
|
{isFetchingNextPage ? 'Loading more...' : 'Load more'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using react-intersection-observer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
|
||||||
|
function InfiniteScrollPosts() {
|
||||||
|
const { ref, inView } = useInView();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [inView, fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data?.pages.map((page, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{page.posts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={ref}>{isFetchingNextPage && 'Loading...'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refetching and Invalidation
|
||||||
|
|
||||||
|
### Refetch All Pages
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Refetch all pages
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
|
|
||||||
|
// Or manually
|
||||||
|
const { refetch } = useInfiniteQuery({ /* ... */ });
|
||||||
|
refetch();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refetch Only First Page
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
refetchPage: (page, index) => index === 0,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refetch Specific Pages
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Refetch first 3 pages
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
refetchPage: (page, index) => index < 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refetch based on page content
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
refetchPage: (page, index) => {
|
||||||
|
// Refetch if page has a specific item
|
||||||
|
return page.posts.some(post => post.id === targetId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transforming Data
|
||||||
|
|
||||||
|
### Flatten Pages
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data } = useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: fetchPosts,
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
select: (data) => ({
|
||||||
|
pages: [...data.pages],
|
||||||
|
pageParams: [...data.pageParams],
|
||||||
|
// Flatten all posts
|
||||||
|
allPosts: data.pages.flatMap(page => page.posts),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now you can use data.allPosts directly
|
||||||
|
return <div>{data?.allPosts.map(post => <PostCard key={post.id} post={post} />)}</div>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter and Transform
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data } = useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: fetchPosts,
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
select: (data) => ({
|
||||||
|
...data,
|
||||||
|
pages: data.pages.map(page => ({
|
||||||
|
...page,
|
||||||
|
posts: page.posts.filter(post => !post.isDeleted),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bidirectional Infinite Scrolling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function BidirectionalScroll() {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
fetchPreviousPage,
|
||||||
|
hasNextPage,
|
||||||
|
hasPreviousPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isFetchingPreviousPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['messages'],
|
||||||
|
queryFn: ({ pageParam }) => fetchMessages(pageParam),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
getPreviousPageParam: (firstPage) => firstPage.previousCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchPreviousPage()}
|
||||||
|
disabled={!hasPreviousPage || isFetchingPreviousPage}
|
||||||
|
>
|
||||||
|
{isFetchingPreviousPage ? 'Loading...' : 'Load Older'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{data?.pages.map((page, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{page.messages.map((message) => (
|
||||||
|
<MessageCard key={message.id} message={message} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={!hasNextPage || isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? 'Loading...' : 'Load Newer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Patterns
|
||||||
|
|
||||||
|
### Search with Infinite Scroll
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchResults({ searchTerm }) {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['search', searchTerm],
|
||||||
|
queryFn: ({ pageParam = 0 }) =>
|
||||||
|
fetch(`/api/search?q=${searchTerm}&cursor=${pageParam}`).then(r => r.json()),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
enabled: searchTerm.length > 2, // Only search if term is long enough
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data?.pages.map((page, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{page.results.map((result) => (
|
||||||
|
<SearchResult key={result.id} result={result} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
|
||||||
|
Load More Results
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infinite Query with Filters
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function FilteredList({ filters }) {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['items', filters],
|
||||||
|
queryFn: ({ pageParam = 0 }) =>
|
||||||
|
fetchItems({ ...filters, cursor: pageParam }),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When filters change, query automatically resets and refetches from page 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data?.pages.map((page, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{page.items.map((item) => (
|
||||||
|
<ItemCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{hasNextPage && <button onClick={() => fetchNextPage()}>Load More</button>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prefetching Next Page
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Posts() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prefetch next page when user is near the end
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
const nextPageParam = data?.pageParams[data.pageParams.length - 1] + 1;
|
||||||
|
queryClient.prefetchInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: ({ pageParam }) => fetchPosts(pageParam),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
pages: data?.pages.length + 1, // Prefetch one more page
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, hasNextPage, queryClient]);
|
||||||
|
|
||||||
|
return <div>{/* render */}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues and Solutions
|
||||||
|
|
||||||
|
### Duplicate Data After Invalidation
|
||||||
|
|
||||||
|
When invalidating an infinite query, it refetches all pages. To avoid duplicates:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Option 1: Use refetchPage to only refetch specific pages
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
refetchPage: (page, index) => index === 0, // Only refetch first page
|
||||||
|
});
|
||||||
|
|
||||||
|
// Option 2: Reset to first page
|
||||||
|
queryClient.resetQueries({ queryKey: ['posts'] });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stale Data Between Pages
|
||||||
|
|
||||||
|
Set appropriate `staleTime`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: fetchPosts,
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Total Count
|
||||||
|
|
||||||
|
Track total items across all pages:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data } = useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: fetchPosts,
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
select: (data) => ({
|
||||||
|
...data,
|
||||||
|
totalCount: data.pages[0]?.total || 0, // Assuming API returns total
|
||||||
|
currentCount: data.pages.reduce((acc, page) => acc + page.posts.length, 0),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display: Showing {data.currentCount} of {data.totalCount}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Choose the Right Pagination Strategy**
|
||||||
|
- Use cursor-based for real-time feeds
|
||||||
|
- Use offset for simple lists
|
||||||
|
- Use page numbers for traditional pagination
|
||||||
|
|
||||||
|
2. **Handle Edge Cases**
|
||||||
|
- Empty states when no data
|
||||||
|
- Loading states for first page
|
||||||
|
- Error states with retry
|
||||||
|
- End of list indicators
|
||||||
|
|
||||||
|
3. **Optimize Performance**
|
||||||
|
- Use `select` to transform data once
|
||||||
|
- Set appropriate `staleTime` and `gcTime`
|
||||||
|
- Implement virtual scrolling for large lists (react-window, react-virtualized)
|
||||||
|
|
||||||
|
4. **Refetch Strategies**
|
||||||
|
- Only refetch first page for most updates
|
||||||
|
- Use `refetchPage` for targeted refetches
|
||||||
|
- Consider resetting queries when filters change
|
||||||
|
|
||||||
|
5. **User Experience**
|
||||||
|
- Show loading indicators for next/previous pages
|
||||||
|
- Disable buttons during fetching
|
||||||
|
- Provide feedback when no more data
|
||||||
|
- Handle errors gracefully with retry options
|
||||||
616
skills/skill/references/optimistic-updates.md
Normal file
616
skills/skill/references/optimistic-updates.md
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
# Optimistic Updates
|
||||||
|
|
||||||
|
Optimistic updates allow you to update the UI immediately before a mutation completes, providing a better user experience. If the mutation fails, you can roll back to the previous state.
|
||||||
|
|
||||||
|
## Basic Optimistic Update
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
function TodoList() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const toggleTodo = useMutation({
|
||||||
|
mutationFn: (todoId) => {
|
||||||
|
return fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
onMutate: async (todoId) => {
|
||||||
|
// Cancel outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
// Optimistically update
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.map((todo) =>
|
||||||
|
todo.id === todoId ? { ...todo, done: !todo.done } : todo
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return context with previous value
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onError: (err, todoId, context) => {
|
||||||
|
// Rollback on error
|
||||||
|
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
// Refetch after mutation completes
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* render todos with toggle */}
|
||||||
|
<button onClick={() => toggleTodo.mutate(todoId)}>Toggle</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mutation Lifecycle
|
||||||
|
|
||||||
|
Understanding the mutation lifecycle is crucial for optimistic updates:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: updateTodo,
|
||||||
|
|
||||||
|
// 1. Before mutation function runs
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
// Cancel queries, snapshot data, optimistically update
|
||||||
|
// Return context object
|
||||||
|
return { previousData };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. If mutation succeeds
|
||||||
|
onSuccess: (data, variables, context) => {
|
||||||
|
// Handle successful mutation
|
||||||
|
// data = mutation function response
|
||||||
|
// variables = mutation variables
|
||||||
|
// context = returned from onMutate
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. If mutation fails
|
||||||
|
onError: (error, variables, context) => {
|
||||||
|
// Rollback optimistic update
|
||||||
|
// error = error object
|
||||||
|
// context = returned from onMutate
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. Always runs after success or error
|
||||||
|
onSettled: (data, error, variables, context) => {
|
||||||
|
// Refetch to sync with server
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optimistic Update Patterns
|
||||||
|
|
||||||
|
### Adding an Item
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const addTodo = useMutation({
|
||||||
|
mutationFn: (newTodo) => {
|
||||||
|
return fetch('/api/todos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(newTodo),
|
||||||
|
}).then(res => res.json());
|
||||||
|
},
|
||||||
|
onMutate: async (newTodo) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
// Add optimistic todo with temporary ID
|
||||||
|
queryClient.setQueryData(['todos'], (old) => [
|
||||||
|
...old,
|
||||||
|
{ ...newTodo, id: 'temp-' + Date.now(), status: 'pending' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onError: (err, newTodo, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
// Replace temporary item with real server response
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.map((todo) =>
|
||||||
|
todo.id.toString().startsWith('temp-') ? data : todo
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating an Item
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: ({ id, updates }) => {
|
||||||
|
return fetch(`/api/todos/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
}).then(res => res.json());
|
||||||
|
},
|
||||||
|
onMutate: async ({ id, updates }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.map((todo) =>
|
||||||
|
todo.id === id ? { ...todo, ...updates } : todo
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
updateTodo.mutate({ id: 1, updates: { title: 'Updated title' } });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deleting an Item
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const deleteTodo = useMutation({
|
||||||
|
mutationFn: (todoId) => {
|
||||||
|
return fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
onMutate: async (todoId) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.filter((todo) => todo.id !== todoId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onError: (err, todoId, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Query Updates
|
||||||
|
|
||||||
|
Update multiple related queries optimistically:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const updateUser = useMutation({
|
||||||
|
mutationFn: ({ userId, updates }) => {
|
||||||
|
return fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
}).then(res => res.json());
|
||||||
|
},
|
||||||
|
onMutate: async ({ userId, updates }) => {
|
||||||
|
// Cancel all related queries
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['users'] });
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['user', userId] });
|
||||||
|
|
||||||
|
// Snapshot previous data
|
||||||
|
const previousUsers = queryClient.getQueryData(['users']);
|
||||||
|
const previousUser = queryClient.getQueryData(['user', userId]);
|
||||||
|
|
||||||
|
// Update users list
|
||||||
|
queryClient.setQueryData(['users'], (old) =>
|
||||||
|
old?.map((user) =>
|
||||||
|
user.id === userId ? { ...user, ...updates } : user
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update individual user
|
||||||
|
queryClient.setQueryData(['user', userId], (old) => ({
|
||||||
|
...old,
|
||||||
|
...updates,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { previousUsers, previousUser };
|
||||||
|
},
|
||||||
|
onError: (err, { userId }, context) => {
|
||||||
|
// Rollback both queries
|
||||||
|
queryClient.setQueryData(['users'], context.previousUsers);
|
||||||
|
queryClient.setQueryData(['user', userId], context.previousUser);
|
||||||
|
},
|
||||||
|
onSettled: (data, error, { userId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['user', userId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optimistic Updates with Infinite Queries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const addPost = useMutation({
|
||||||
|
mutationFn: (newPost) => {
|
||||||
|
return fetch('/api/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(newPost),
|
||||||
|
}).then(res => res.json());
|
||||||
|
},
|
||||||
|
onMutate: async (newPost) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['posts'] });
|
||||||
|
const previousPosts = queryClient.getQueryData(['posts']);
|
||||||
|
|
||||||
|
// Add to first page
|
||||||
|
queryClient.setQueryData(['posts'], (old) => {
|
||||||
|
if (!old?.pages.length) return old;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
...old.pages[0],
|
||||||
|
posts: [
|
||||||
|
{ ...newPost, id: 'temp-' + Date.now() },
|
||||||
|
...old.pages[0].posts,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...old.pages.slice(1),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousPosts };
|
||||||
|
},
|
||||||
|
onError: (err, newPost, context) => {
|
||||||
|
queryClient.setQueryData(['posts'], context.previousPosts);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Feedback During Optimistic Updates
|
||||||
|
|
||||||
|
### Show Pending State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TodoItem({ todo }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const toggleTodo = useMutation({
|
||||||
|
mutationFn: (todoId) => fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' }),
|
||||||
|
onMutate: async (todoId) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.map((t) =>
|
||||||
|
t.id === todoId
|
||||||
|
? { ...t, done: !t.done, isPending: true } // Mark as pending
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onSuccess: (data, todoId) => {
|
||||||
|
// Remove pending state
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.map((t) =>
|
||||||
|
t.id === todoId ? { ...t, isPending: false } : t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (err, todoId, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={todo.isPending ? 'opacity-50' : ''}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={todo.done}
|
||||||
|
onChange={() => toggleTodo.mutate(todo.id)}
|
||||||
|
disabled={todo.isPending}
|
||||||
|
/>
|
||||||
|
{todo.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Show Error State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: updateTodoApi,
|
||||||
|
onMutate: async (updates) => {
|
||||||
|
setError(null); // Clear previous errors
|
||||||
|
// ... optimistic update
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
setError(err.message);
|
||||||
|
// ... rollback
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
{/* render todo */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Patterns
|
||||||
|
|
||||||
|
### Optimistic Update with Retry
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: updateTodoApi,
|
||||||
|
retry: 3,
|
||||||
|
onMutate: async (updates) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.map((todo) =>
|
||||||
|
todo.id === updates.id
|
||||||
|
? { ...todo, ...updates, _optimistic: true }
|
||||||
|
: todo
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Remove optimistic flag
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.map((todo) =>
|
||||||
|
todo.id === variables.id
|
||||||
|
? { ...todo, _optimistic: false }
|
||||||
|
: todo
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
// Only rollback if all retries failed
|
||||||
|
if (err.retryCount >= 3) {
|
||||||
|
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debounced Optimistic Updates
|
||||||
|
|
||||||
|
For rapid updates like typing in a search or editing text:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
|
function TodoTitle({ todo }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [localTitle, setLocalTitle] = useState(todo.title);
|
||||||
|
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: ({ id, title }) => {
|
||||||
|
return fetch(`/api/todos/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
}).then(res => res.json());
|
||||||
|
},
|
||||||
|
onMutate: async ({ id, title }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.map((t) => (t.id === id ? { ...t, title } : t))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||||
|
setLocalTitle(context.previousTodos.find(t => t.id === variables.id).title);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const debouncedUpdate = useDebouncedCallback(
|
||||||
|
(id, title) => updateTodo.mutate({ id, title }),
|
||||||
|
500
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const newTitle = e.target.value;
|
||||||
|
setLocalTitle(newTitle);
|
||||||
|
debouncedUpdate(todo.id, newTitle);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <input value={localTitle} onChange={handleChange} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optimistic Delete with Undo
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TodoItem({ todo }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showUndo, setShowUndo] = useState(false);
|
||||||
|
|
||||||
|
const deleteTodo = useMutation({
|
||||||
|
mutationFn: (todoId) => {
|
||||||
|
return fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
onMutate: async (todoId) => {
|
||||||
|
setShowUndo(true);
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.filter((t) => t.id !== todoId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-hide undo after 5 seconds
|
||||||
|
setTimeout(() => setShowUndo(false), 5000);
|
||||||
|
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onError: (err, todoId, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||||
|
setShowUndo(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
deleteTodo.reset(); // Reset mutation state
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
setShowUndo(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showUndo) {
|
||||||
|
return (
|
||||||
|
<div className="undo-banner">
|
||||||
|
Todo deleted <button onClick={handleUndo}>Undo</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{todo.title}
|
||||||
|
<button onClick={() => deleteTodo.mutate(todo.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch Optimistic Updates
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const markAllDone = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
return fetch('/api/todos/mark-all-done', { method: 'POST' });
|
||||||
|
},
|
||||||
|
onMutate: async () => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.map((todo) => ({ ...todo, done: true }))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cancel In-Flight Mutations
|
||||||
|
|
||||||
|
Cancel mutations that are no longer needed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function QuickEdit({ todo }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: ({ id, title }) => {
|
||||||
|
return fetch(`/api/todos/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
}).then(res => res.json());
|
||||||
|
},
|
||||||
|
onMutate: async ({ id, title }) => {
|
||||||
|
// Cancel previous mutations for this todo
|
||||||
|
queryClient.cancelMutations({ mutationKey: ['updateTodo', id] });
|
||||||
|
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old.map((t) => (t.id === id ? { ...t, title } : t))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
onChange={(e) => updateTodo.mutate({ id: todo.id, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always Cancel Queries**
|
||||||
|
```tsx
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
```
|
||||||
|
Prevents race conditions between optimistic update and ongoing fetches.
|
||||||
|
|
||||||
|
2. **Always Return Context**
|
||||||
|
```tsx
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
const previousData = queryClient.getQueryData(['todos']);
|
||||||
|
// ... update
|
||||||
|
return { previousData }; // Critical for rollback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Always Handle Errors**
|
||||||
|
```tsx
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context.previousData);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Use onSettled for Refetch**
|
||||||
|
```tsx
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Ensures data stays in sync with server.
|
||||||
|
|
||||||
|
5. **Show Visual Feedback**
|
||||||
|
- Add loading/pending states to optimistically updated items
|
||||||
|
- Show error messages on failure
|
||||||
|
- Provide undo functionality where appropriate
|
||||||
|
|
||||||
|
6. **Handle Multiple Related Queries**
|
||||||
|
- Update all queries that display the same data
|
||||||
|
- Rollback all queries on error
|
||||||
|
|
||||||
|
7. **Consider Using Temporary IDs**
|
||||||
|
- For created items, use temp IDs until server responds
|
||||||
|
- Replace with server IDs on success
|
||||||
|
|
||||||
|
8. **Test Error Cases**
|
||||||
|
- Verify rollback works correctly
|
||||||
|
- Test network failures
|
||||||
|
- Test validation errors from server
|
||||||
653
skills/skill/references/performance.md
Normal file
653
skills/skill/references/performance.md
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
# Performance Optimization
|
||||||
|
|
||||||
|
Optimize TanStack Query for better performance, reduced network requests, and improved user experience.
|
||||||
|
|
||||||
|
## Query Configuration
|
||||||
|
|
||||||
|
### staleTime
|
||||||
|
|
||||||
|
Control how long data is considered fresh:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Default - data stale immediately
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
staleTime: 0, // default
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Optimized - data fresh for 5 minutes
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Static data - never stale
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['config'],
|
||||||
|
queryFn: fetchConfig,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### gcTime (formerly cacheTime)
|
||||||
|
|
||||||
|
Control how long unused data stays in cache:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Default - 5 minutes
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
gcTime: 1000 * 60 * 5, // default
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extended cache for frequently accessed data
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['user-profile'],
|
||||||
|
queryFn: fetchUserProfile,
|
||||||
|
gcTime: 1000 * 60 * 30, // 30 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Immediate cleanup for sensitive data
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['payment-info'],
|
||||||
|
queryFn: fetchPaymentInfo,
|
||||||
|
gcTime: 0, // Remove immediately when unused
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable Unnecessary Refetching
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Disable all automatic refetching
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['static-data'],
|
||||||
|
queryFn: fetchStaticData,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global defaults
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Deduplication
|
||||||
|
|
||||||
|
TanStack Query automatically deduplicates identical requests:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// These three components all request the same data
|
||||||
|
function Component1() {
|
||||||
|
useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
|
||||||
|
}
|
||||||
|
|
||||||
|
function Component2() {
|
||||||
|
useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
|
||||||
|
}
|
||||||
|
|
||||||
|
function Component3() {
|
||||||
|
useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result: Only ONE network request is made
|
||||||
|
// All three components share the same cached data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structural Sharing
|
||||||
|
|
||||||
|
TanStack Query preserves referential equality when data hasn't changed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
// Structural sharing is enabled by default
|
||||||
|
structuralSharing: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If server returns identical data, React won't re-render
|
||||||
|
// because data reference hasn't changed
|
||||||
|
```
|
||||||
|
|
||||||
|
Disable for very large datasets:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['large-dataset'],
|
||||||
|
queryFn: fetchLargeDataset,
|
||||||
|
structuralSharing: false, // Skip structural sharing for performance
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prefetching
|
||||||
|
|
||||||
|
Load data before it's needed:
|
||||||
|
|
||||||
|
### Hover Prefetch
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
function TodoListItem({ todo }) {
|
||||||
|
const prefetchTodo = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ['todo', todo.id],
|
||||||
|
queryFn: () => fetchTodo(todo.id),
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/todo/${todo.id}`}
|
||||||
|
onMouseEnter={prefetchTodo}
|
||||||
|
onFocus={prefetchTodo}
|
||||||
|
>
|
||||||
|
{todo.title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route-Based Prefetch
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In router loader or component
|
||||||
|
async function todoLoader({ params }) {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: ['todo', params.id],
|
||||||
|
queryFn: () => fetchTodo(params.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or in a parent component
|
||||||
|
function TodoLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Prefetch common routes
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Predictive Prefetch
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PaginatedList({ page }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['items', page],
|
||||||
|
queryFn: () => fetchItems(page),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prefetch next page
|
||||||
|
useEffect(() => {
|
||||||
|
if (page < totalPages) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ['items', page + 1],
|
||||||
|
queryFn: () => fetchItems(page + 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return <div>{/* render items */}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Transformation
|
||||||
|
|
||||||
|
### Use select for Transformation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Transform in component - runs on every render
|
||||||
|
function TodoList() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedTodos = data?.filter(todo => todo.completed);
|
||||||
|
return <div>{completedTodos?.map(/* ... */)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Transform with select - memoized automatically
|
||||||
|
function TodoList() {
|
||||||
|
const { data: completedTodos } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
select: (todos) => todos.filter(todo => todo.completed),
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>{completedTodos?.map(/* ... */)}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Select is Memoized
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// select function only runs when data changes
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
select: (todos) => {
|
||||||
|
console.log('Transforming...'); // Only logs when data changes
|
||||||
|
return todos.map(todo => ({
|
||||||
|
...todo,
|
||||||
|
displayName: `${todo.id}: ${todo.title}`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pagination Optimization
|
||||||
|
|
||||||
|
### Offset Pagination
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PaginatedTodos() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos', page],
|
||||||
|
queryFn: () => fetchTodos(page),
|
||||||
|
staleTime: 1000 * 60 * 5, // Keep pages fresh
|
||||||
|
placeholderData: (previousData) => previousData, // Keep previous data while loading
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data?.items.map(todo => <TodoItem key={todo.id} todo={todo} />)}
|
||||||
|
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPage(p => p + 1)}>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infinite Queries with Windowing
|
||||||
|
|
||||||
|
For very long lists, use virtual scrolling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
|
||||||
|
function VirtualizedInfiniteList() {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['items'],
|
||||||
|
queryFn: ({ pageParam = 0 }) => fetchItems(pageParam),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allItems = data?.pages.flatMap(page => page.items) ?? [];
|
||||||
|
const parentRef = useRef(null);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: hasNextPage ? allItems.length + 1 : allItems.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lastItem = virtualizer.getVirtualItems()[virtualizer.getVirtualItems().length - 1];
|
||||||
|
|
||||||
|
if (!lastItem) return;
|
||||||
|
|
||||||
|
if (lastItem.index >= allItems.length - 1 && hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, fetchNextPage, allItems.length, virtualizer.getVirtualItems()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
|
||||||
|
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => (
|
||||||
|
<div
|
||||||
|
key={virtualRow.index}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allItems[virtualRow.index] ? (
|
||||||
|
<Item item={allItems[virtualRow.index]} />
|
||||||
|
) : (
|
||||||
|
'Loading...'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Queries Optimization
|
||||||
|
|
||||||
|
### Using useQueries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Sequential queries
|
||||||
|
async function fetchAllData() {
|
||||||
|
const users = await fetchUsers();
|
||||||
|
const posts = await fetchPosts();
|
||||||
|
const comments = await fetchComments();
|
||||||
|
return { users, posts, comments };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Parallel queries
|
||||||
|
function Dashboard() {
|
||||||
|
const results = useQueries({
|
||||||
|
queries: [
|
||||||
|
{ queryKey: ['users'], queryFn: fetchUsers },
|
||||||
|
{ queryKey: ['posts'], queryFn: fetchPosts },
|
||||||
|
{ queryKey: ['comments'], queryFn: fetchComments },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [users, posts, comments] = results;
|
||||||
|
const isLoading = results.some(r => r.isLoading);
|
||||||
|
|
||||||
|
return <div>{/* render */}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Parallel Queries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function UserPosts({ userIds }) {
|
||||||
|
const queries = useQueries({
|
||||||
|
queries: userIds.map(id => ({
|
||||||
|
queryKey: ['user-posts', id],
|
||||||
|
queryFn: () => fetchUserPosts(id),
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>{/* render */}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Memory Management
|
||||||
|
|
||||||
|
### Limit Cache Size
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
gcTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manually clear old queries
|
||||||
|
queryClient.clear(); // Clear all cache
|
||||||
|
|
||||||
|
// Remove specific queries
|
||||||
|
queryClient.removeQueries({ queryKey: ['old-data'] });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Queries on Unmount
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ExpensiveComponent() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['expensive-data'],
|
||||||
|
queryFn: fetchExpensiveData,
|
||||||
|
gcTime: 0, // Remove immediately when component unmounts
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>{/* render */}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network Optimization
|
||||||
|
|
||||||
|
### Batch Requests
|
||||||
|
|
||||||
|
If your API supports batching:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Collect query keys and batch them
|
||||||
|
const batchedQueryFn = async (keys) => {
|
||||||
|
const ids = keys.map(key => key[1]);
|
||||||
|
const results = await fetch(`/api/items?ids=${ids.join(',')}`);
|
||||||
|
return results.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use in queries
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['item', itemId],
|
||||||
|
queryFn: () => batchedQueryFn([['item', itemId]]),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Cancellation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['search', searchTerm],
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
// AbortSignal automatically provided
|
||||||
|
const res = await fetch(`/api/search?q=${searchTerm}`, { signal });
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// When searchTerm changes, previous request is cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retry Configuration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Retry immediately 3 times
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
retry: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Exponential backoff
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
retry: 3,
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependent Queries
|
||||||
|
|
||||||
|
Avoid waterfalls by enabling queries in parallel when possible:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Waterfall - queries run sequentially
|
||||||
|
function UserDashboard({ userId }) {
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['user', userId],
|
||||||
|
queryFn: () => fetchUser(userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: posts } = useQuery({
|
||||||
|
queryKey: ['posts', user?.id],
|
||||||
|
queryFn: () => fetchPosts(user.id),
|
||||||
|
enabled: !!user?.id, // Waits for user
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: comments } = useQuery({
|
||||||
|
queryKey: ['comments', user?.id],
|
||||||
|
queryFn: () => fetchComments(user.id),
|
||||||
|
enabled: !!user?.id, // Also waits for user
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Optimized - posts and comments fetch in parallel after user loads
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Splitting
|
||||||
|
|
||||||
|
### Lazy Load Query Client
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const ReactQueryDevtools = lazy(() =>
|
||||||
|
import('@tanstack/react-query-devtools').then(mod => ({
|
||||||
|
default: mod.ReactQueryDevtools,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<YourApp />
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ReactQueryDevtools />
|
||||||
|
</Suspense>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring Performance
|
||||||
|
|
||||||
|
### Using DevTools
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<YourApp />
|
||||||
|
<ReactQueryDevtools
|
||||||
|
initialIsOpen={false}
|
||||||
|
position="bottom-right"
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Logger
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
logger: {
|
||||||
|
log: (...args) => console.log(...args),
|
||||||
|
warn: (...args) => console.warn(...args),
|
||||||
|
error: (...args) => console.error(...args),
|
||||||
|
},
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
onSuccess: (data, query) => {
|
||||||
|
console.log(`Query ${query.queryKey} succeeded`, data);
|
||||||
|
},
|
||||||
|
onError: (error, query) => {
|
||||||
|
console.error(`Query ${query.queryKey} failed`, error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Metrics
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const start = performance.now();
|
||||||
|
const data = await fetchTodos();
|
||||||
|
const duration = performance.now() - start;
|
||||||
|
console.log(`Query took ${duration}ms`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Set Appropriate staleTime**
|
||||||
|
```tsx
|
||||||
|
// Static data
|
||||||
|
staleTime: Infinity
|
||||||
|
|
||||||
|
// Frequently changing
|
||||||
|
staleTime: 0
|
||||||
|
|
||||||
|
// Moderate
|
||||||
|
staleTime: 1000 * 60 * 5 // 5 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use Prefetching**
|
||||||
|
- Hover intent
|
||||||
|
- Route prediction
|
||||||
|
- Next page in pagination
|
||||||
|
|
||||||
|
3. **Optimize with select**
|
||||||
|
```tsx
|
||||||
|
select: (data) => data.filter(/* ... */)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Disable Unnecessary Refetching**
|
||||||
|
```tsx
|
||||||
|
refetchOnWindowFocus: false
|
||||||
|
refetchOnReconnect: false
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Use Structural Sharing**
|
||||||
|
- Enabled by default
|
||||||
|
- Disable for very large datasets
|
||||||
|
|
||||||
|
6. **Implement Virtual Scrolling**
|
||||||
|
- For long lists
|
||||||
|
- For infinite queries
|
||||||
|
|
||||||
|
7. **Monitor with DevTools**
|
||||||
|
- Watch for unnecessary refetches
|
||||||
|
- Check cache effectiveness
|
||||||
|
- Identify slow queries
|
||||||
|
|
||||||
|
8. **Batch Parallel Queries**
|
||||||
|
- Use useQueries
|
||||||
|
- Reduce waterfalls
|
||||||
|
|
||||||
|
9. **Clean Up Unused Cache**
|
||||||
|
```tsx
|
||||||
|
gcTime: 1000 * 60 * 5
|
||||||
|
```
|
||||||
|
|
||||||
|
10. **Use Request Cancellation**
|
||||||
|
- Automatically handled by TanStack Query
|
||||||
|
- Ensures old requests don't override new ones
|
||||||
649
skills/skill/references/query-invalidation.md
Normal file
649
skills/skill/references/query-invalidation.md
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
# Query Invalidation
|
||||||
|
|
||||||
|
Query invalidation is the process of marking queries as stale and potentially refetching them. This is essential for keeping your cache in sync with server state after mutations.
|
||||||
|
|
||||||
|
## Basic Invalidation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
function CreateTodo() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (newTodo) => {
|
||||||
|
return fetch('/api/todos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(newTodo),
|
||||||
|
}).then(res => res.json());
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Mark todos queries as stale and refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={() => mutation.mutate({ title: 'New Todo' })}>
|
||||||
|
Create Todo
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invalidation Methods
|
||||||
|
|
||||||
|
### invalidateQueries
|
||||||
|
|
||||||
|
Marks queries as stale and triggers refetch of active queries:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Invalidate all queries
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
|
||||||
|
// Invalidate specific query
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
|
||||||
|
// Invalidate query with exact match
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todo', todoId], exact: true });
|
||||||
|
|
||||||
|
// Invalidate and wait for refetch
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
```
|
||||||
|
|
||||||
|
### refetchQueries
|
||||||
|
|
||||||
|
Directly refetch queries without marking as stale first:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Refetch all queries
|
||||||
|
queryClient.refetchQueries();
|
||||||
|
|
||||||
|
// Refetch specific queries
|
||||||
|
queryClient.refetchQueries({ queryKey: ['todos'] });
|
||||||
|
|
||||||
|
// Refetch only active queries
|
||||||
|
queryClient.refetchQueries({ queryKey: ['todos'], type: 'active' });
|
||||||
|
|
||||||
|
// Refetch only inactive queries
|
||||||
|
queryClient.refetchQueries({ queryKey: ['todos'], type: 'inactive' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### resetQueries
|
||||||
|
|
||||||
|
Reset queries to their initial state:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Reset and refetch
|
||||||
|
queryClient.resetQueries({ queryKey: ['todos'] });
|
||||||
|
|
||||||
|
// Reset specific query
|
||||||
|
queryClient.resetQueries({ queryKey: ['todo', todoId] });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Key Matching
|
||||||
|
|
||||||
|
### Prefix Matching
|
||||||
|
|
||||||
|
By default, invalidateQueries uses prefix matching:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// This query
|
||||||
|
useQuery({ queryKey: ['todos', 'list', { page: 1 }], queryFn: fetchTodos });
|
||||||
|
|
||||||
|
// Is invalidated by any of these:
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list', { page: 1 }] });
|
||||||
|
|
||||||
|
// But NOT by these:
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'detail'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exact Matching
|
||||||
|
|
||||||
|
Use `exact: true` for precise matching:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Only invalidate this exact query key
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todos', 'list', { page: 1 }],
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// This would invalidate:
|
||||||
|
useQuery({ queryKey: ['todos', 'list', { page: 1 }], ... });
|
||||||
|
|
||||||
|
// But NOT these:
|
||||||
|
useQuery({ queryKey: ['todos', 'list', { page: 2 }], ... });
|
||||||
|
useQuery({ queryKey: ['todos', 'list'], ... });
|
||||||
|
useQuery({ queryKey: ['todos'], ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Predicate Functions
|
||||||
|
|
||||||
|
Use custom matching logic:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Invalidate all todos queries except detail queries
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => {
|
||||||
|
return query.queryKey[0] === 'todos' && query.queryKey[1] !== 'detail';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate stale queries only
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => {
|
||||||
|
return query.state.isInvalidated;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate based on query data
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => {
|
||||||
|
const data = query.state.data as Todo[] | undefined;
|
||||||
|
return data?.some((todo) => todo.userId === targetUserId) ?? false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invalidation Timing
|
||||||
|
|
||||||
|
### Immediate Invalidation
|
||||||
|
|
||||||
|
Invalidate and refetch immediately:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createTodo,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delayed Invalidation
|
||||||
|
|
||||||
|
Wait for mutation to settle:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createTodo,
|
||||||
|
onSettled: () => {
|
||||||
|
// Runs after success or error
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Invalidation
|
||||||
|
|
||||||
|
Only invalidate under certain conditions:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: updateTodo,
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
if (data.isPublished) {
|
||||||
|
// Only invalidate if todo was published
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'published'] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refetch Strategies
|
||||||
|
|
||||||
|
### Refetch Active Queries Only
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
refetchType: 'active', // Only refetch active queries (default)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refetch All Queries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
refetchType: 'all', // Refetch both active and inactive queries
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Don't Refetch
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
refetchType: 'none', // Only mark as stale, don't refetch
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invalidation Patterns
|
||||||
|
|
||||||
|
### After Create
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const createTodo = useMutation({
|
||||||
|
mutationFn: (newTodo) => fetch('/api/todos', { method: 'POST', ... }),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate list queries to show new item
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Update
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: ({ id, updates }) => fetch(`/api/todos/${id}`, { method: 'PATCH', ... }),
|
||||||
|
onSuccess: (data, { id }) => {
|
||||||
|
// Invalidate specific item
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todo', id] });
|
||||||
|
// Invalidate list in case item moved categories, etc.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Delete
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const deleteTodo = useMutation({
|
||||||
|
mutationFn: (id) => fetch(`/api/todos/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: (_, id) => {
|
||||||
|
// Remove specific item from cache
|
||||||
|
queryClient.removeQueries({ queryKey: ['todo', id] });
|
||||||
|
// Invalidate lists
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bulk Operations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const markAllDone = useMutation({
|
||||||
|
mutationFn: () => fetch('/api/todos/mark-all-done', { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate all todo-related queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Query Invalidation
|
||||||
|
|
||||||
|
### Update Multiple Related Queries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const updateUser = useMutation({
|
||||||
|
mutationFn: ({ userId, updates }) => updateUserApi(userId, updates),
|
||||||
|
onSuccess: (data, { userId }) => {
|
||||||
|
// Invalidate user detail
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['user', userId] });
|
||||||
|
// Invalidate user list
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
// Invalidate user's posts
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts', 'user', userId] });
|
||||||
|
// Invalidate user's comments
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['comments', 'user', userId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hierarchical Invalidation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Query key structure:
|
||||||
|
// ['todos'] - all todos
|
||||||
|
// ['todos', 'list'] - todo lists
|
||||||
|
// ['todos', 'list', filters] - filtered lists
|
||||||
|
// ['todos', 'detail'] - todo details
|
||||||
|
// ['todos', 'detail', id] - specific todo
|
||||||
|
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: updateTodoApi,
|
||||||
|
onSuccess: (data, { id }) => {
|
||||||
|
// Invalidate specific todo detail
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'detail', id] });
|
||||||
|
// Invalidate all list queries (they might show this todo)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invalidation with Infinite Queries
|
||||||
|
|
||||||
|
### Invalidate All Pages
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const createPost = useMutation({
|
||||||
|
mutationFn: (newPost) => createPostApi(newPost),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Refetches all pages
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalidate Specific Pages
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
refetchPage: (page, index) => {
|
||||||
|
// Only refetch first page
|
||||||
|
return index === 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selective Page Refetch
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const updatePost = useMutation({
|
||||||
|
mutationFn: ({ id, updates }) => updatePostApi(id, updates),
|
||||||
|
onSuccess: (data, { id }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
refetchPage: (page, index) => {
|
||||||
|
// Only refetch pages containing this post
|
||||||
|
return page.posts.some((post) => post.id === id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Invalidation
|
||||||
|
|
||||||
|
### Cascading Invalidation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const deleteProject = useMutation({
|
||||||
|
mutationFn: (projectId) => deleteProjectApi(projectId),
|
||||||
|
onSuccess: async (_, projectId) => {
|
||||||
|
// Step 1: Remove project from cache
|
||||||
|
queryClient.removeQueries({ queryKey: ['project', projectId] });
|
||||||
|
|
||||||
|
// Step 2: Invalidate project list
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
|
||||||
|
// Step 3: Invalidate related resources
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['tasks', 'project', projectId] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['members', 'project', projectId] });
|
||||||
|
|
||||||
|
// Step 4: Invalidate summary/stats
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debounced Invalidation
|
||||||
|
|
||||||
|
For frequent updates, debounce invalidation:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
|
function SearchableList() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const debouncedInvalidate = useDebouncedCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['search-results'] });
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const updateFilters = (newFilters) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
debouncedInvalidate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return <FilterPanel onChange={updateFilters} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Throttled Invalidation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
|
const throttledInvalidate = throttle(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['live-data'] });
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// In a websocket listener
|
||||||
|
socket.on('update', () => {
|
||||||
|
throttledInvalidate();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Filters
|
||||||
|
|
||||||
|
Use query filters for more complex matching:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryFilters } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const filters: QueryFilters = {
|
||||||
|
queryKey: ['todos'],
|
||||||
|
type: 'active', // 'active' | 'inactive' | 'all'
|
||||||
|
stale: true, // Only stale queries
|
||||||
|
exact: false, // Prefix matching
|
||||||
|
predicate: (query) => {
|
||||||
|
// Custom logic
|
||||||
|
return query.state.dataUpdatedAt > Date.now() - 60000;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
queryClient.invalidateQueries(filters);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Only invalidate stale queries
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
stale: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only invalidate fetching queries
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => query.state.fetchStatus === 'fetching',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by Type
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Only active queries (currently mounted)
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
type: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only inactive queries (not mounted)
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
type: 'inactive',
|
||||||
|
});
|
||||||
|
|
||||||
|
// All queries
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
type: 'all',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Batch Invalidations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Multiple separate invalidations
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
|
||||||
|
// ✅ Batch with predicate
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => {
|
||||||
|
const key = query.queryKey[0];
|
||||||
|
return key === 'todos' || key === 'users' || key === 'projects';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smart Invalidation
|
||||||
|
|
||||||
|
Only invalidate what's needed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: updateTodoApi,
|
||||||
|
onSuccess: (data, { id, updates }) => {
|
||||||
|
// If only title changed, no need to invalidate lists
|
||||||
|
if (Object.keys(updates).length === 1 && 'title' in updates) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todo', id], exact: true });
|
||||||
|
} else {
|
||||||
|
// Status/category changed, invalidate lists too
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todo', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prevent Over-Invalidation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Too broad - invalidates everything
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
|
||||||
|
// ❌ Still too broad - invalidates all todo queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
|
||||||
|
// ✅ Specific - only invalidates affected queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list', filters] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternatives to Invalidation
|
||||||
|
|
||||||
|
Sometimes you don't need invalidation:
|
||||||
|
|
||||||
|
### Direct Cache Update
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const toggleTodo = useMutation({
|
||||||
|
mutationFn: (todoId) => toggleTodoApi(todoId),
|
||||||
|
onSuccess: (data, todoId) => {
|
||||||
|
// Directly update cache instead of invalidating
|
||||||
|
queryClient.setQueryData(['todo', todoId], data);
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old?.map((todo) => (todo.id === todoId ? data : todo))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optimistic Updates
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: updateTodoApi,
|
||||||
|
onMutate: async ({ id, updates }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
const previous = queryClient.getQueryData(['todos']);
|
||||||
|
|
||||||
|
// Update cache optimistically
|
||||||
|
queryClient.setQueryData(['todos'], (old) =>
|
||||||
|
old?.map((todo) => (todo.id === id ? { ...todo, ...updates } : todo))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (err, vars, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context.previous);
|
||||||
|
},
|
||||||
|
// No need for invalidation if optimistic update is accurate
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Polling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Instead of manual invalidation, use automatic refetching
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['live-data'],
|
||||||
|
queryFn: fetchLiveData,
|
||||||
|
refetchInterval: 5000, // Auto-refetch every 5 seconds
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Be Specific with Query Keys**
|
||||||
|
```tsx
|
||||||
|
// ✅ Good - specific invalidation
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list', filters] });
|
||||||
|
|
||||||
|
// ❌ Bad - too broad
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use Exact Matching When Appropriate**
|
||||||
|
```tsx
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todo', todoId],
|
||||||
|
exact: true, // Only this specific todo
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Invalidate in onSuccess for Success-Only**
|
||||||
|
```tsx
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Invalidate in onSettled for Always**
|
||||||
|
```tsx
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Consider Alternatives**
|
||||||
|
- Direct cache updates for simple changes
|
||||||
|
- Optimistic updates for better UX
|
||||||
|
- Polling for real-time data
|
||||||
|
|
||||||
|
6. **Batch Related Invalidations**
|
||||||
|
```tsx
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stats'] }),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Use Predicate Functions for Complex Logic**
|
||||||
|
```tsx
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => {
|
||||||
|
// Custom matching logic
|
||||||
|
return shouldInvalidate(query);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Monitor Invalidation Performance**
|
||||||
|
- Use React Query DevTools
|
||||||
|
- Check for unnecessary refetches
|
||||||
|
- Optimize query key structure
|
||||||
813
skills/skill/references/typescript.md
Normal file
813
skills/skill/references/typescript.md
Normal file
@@ -0,0 +1,813 @@
|
|||||||
|
# TypeScript Guide
|
||||||
|
|
||||||
|
TanStack Query v5 is written in TypeScript and provides excellent type safety and inference out of the box.
|
||||||
|
|
||||||
|
## Basic Type Inference
|
||||||
|
|
||||||
|
TanStack Query infers types from your query function return values:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface Todo {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTodos() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async (): Promise<Todo[]> => {
|
||||||
|
const res = await fetch('/api/todos');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodoList() {
|
||||||
|
const { data } = useTodos();
|
||||||
|
// data is automatically typed as Todo[] | undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{data?.map((todo) => (
|
||||||
|
// todo is typed as Todo
|
||||||
|
<li key={todo.id}>{todo.title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typing Query Functions
|
||||||
|
|
||||||
|
### Inline Query Functions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todo', todoId],
|
||||||
|
queryFn: async (): Promise<Todo> => {
|
||||||
|
const res = await fetch(`/api/todos/${todoId}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// data is typed as Todo | undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extracted Query Functions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function fetchTodo(id: number): Promise<Todo> {
|
||||||
|
const res = await fetch(`/api/todos/${id}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todo', todoId],
|
||||||
|
queryFn: () => fetchTodo(todoId),
|
||||||
|
});
|
||||||
|
// data is automatically typed as Todo | undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Functions with QueryKey
|
||||||
|
|
||||||
|
Access the query key in your function with proper typing:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryFunction } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const fetchTodo: QueryFunction<Todo, ['todo', number]> = async ({ queryKey }) => {
|
||||||
|
const [_, id] = queryKey;
|
||||||
|
// id is typed as number
|
||||||
|
const res = await fetch(`/api/todos/${id}`);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todo', todoId],
|
||||||
|
queryFn: fetchTodo,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typing Mutations
|
||||||
|
|
||||||
|
### Basic Mutation Types
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface CreateTodoInput {
|
||||||
|
title: string;
|
||||||
|
done?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateTodoResponse {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
done: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (input: CreateTodoInput): Promise<CreateTodoResponse> => {
|
||||||
|
const res = await fetch('/api/todos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// TypeScript knows:
|
||||||
|
// - mutation.mutate expects CreateTodoInput
|
||||||
|
// - mutation.data is CreateTodoResponse | undefined
|
||||||
|
mutation.mutate({ title: 'New Todo' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generic Mutation Type
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { UseMutationResult } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
type CreateTodoMutation = UseMutationResult<
|
||||||
|
CreateTodoResponse, // TData - successful response
|
||||||
|
Error, // TError - error type
|
||||||
|
CreateTodoInput, // TVariables - mutation input
|
||||||
|
unknown // TContext - context from onMutate
|
||||||
|
>;
|
||||||
|
|
||||||
|
function useCreateTodo(): CreateTodoMutation {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: CreateTodoInput): Promise<CreateTodoResponse> => {
|
||||||
|
const res = await fetch('/api/todos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Typing
|
||||||
|
|
||||||
|
### Typed Errors
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ApiError {
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
details?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = useQuery<Todo[], ApiError>({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch('/api/todos');
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData: ApiError = await res.json();
|
||||||
|
throw errorData;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// error is typed as ApiError
|
||||||
|
console.log(error.message);
|
||||||
|
console.log(error.code);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Type Narrowing
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TodoList() {
|
||||||
|
const { data, error, isError } = useQuery<Todo[], ApiError>({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
// TypeScript knows error is ApiError here
|
||||||
|
return <div>Error: {error.message} (Code: {error.code})</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript knows data is Todo[] | undefined here
|
||||||
|
return <div>{data?.map(todo => <TodoItem key={todo.id} todo={todo} />)}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generic Type Parameters
|
||||||
|
|
||||||
|
### useQuery Generics
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useQuery<
|
||||||
|
TData, // Type of data returned (inferred from queryFn)
|
||||||
|
TError, // Type of errors (default: Error)
|
||||||
|
TQueryKey // Type of query key (inferred)
|
||||||
|
>({ /* ... */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
Example with all generics:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserError {
|
||||||
|
message: string;
|
||||||
|
statusCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = useQuery<User, UserError, ['user', number]>({
|
||||||
|
queryKey: ['user', userId],
|
||||||
|
queryFn: async ({ queryKey }): Promise<User> => {
|
||||||
|
const [_, id] = queryKey;
|
||||||
|
const res = await fetch(`/api/users/${id}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw { message: 'Failed to fetch', statusCode: res.status };
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### useMutation Generics
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useMutation<
|
||||||
|
TData, // Type of successful response
|
||||||
|
TError, // Type of error
|
||||||
|
TVariables, // Type of mutation variables
|
||||||
|
TContext // Type of context from onMutate
|
||||||
|
>({ /* ... */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface UpdateTodoInput {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
done?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateTodoResponse {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
done: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateTodoContext {
|
||||||
|
previousTodos: Todo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation<
|
||||||
|
UpdateTodoResponse,
|
||||||
|
ApiError,
|
||||||
|
UpdateTodoInput,
|
||||||
|
UpdateTodoContext
|
||||||
|
>({
|
||||||
|
mutationFn: async (input) => {
|
||||||
|
const res = await fetch(`/api/todos/${input.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? [];
|
||||||
|
// Must return UpdateTodoContext
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onError: (error, variables, context) => {
|
||||||
|
// error: ApiError
|
||||||
|
// variables: UpdateTodoInput
|
||||||
|
// context: UpdateTodoContext | undefined
|
||||||
|
if (context) {
|
||||||
|
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typing QueryClient Methods
|
||||||
|
|
||||||
|
### setQueryData
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Type-safe setQueryData
|
||||||
|
queryClient.setQueryData<Todo[]>(['todos'], (old) => {
|
||||||
|
// old is typed as Todo[] | undefined
|
||||||
|
return old ? [...old, newTodo] : [newTodo];
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### getQueryData
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const todos = queryClient.getQueryData<Todo[]>(['todos']);
|
||||||
|
// todos is typed as Todo[] | undefined
|
||||||
|
|
||||||
|
if (todos) {
|
||||||
|
// TypeScript knows todos is Todo[] here
|
||||||
|
console.log(todos.length);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### invalidateQueries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Type-safe query key
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typing Infinite Queries
|
||||||
|
|
||||||
|
### Basic Infinite Query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface PostsPage {
|
||||||
|
posts: Post[];
|
||||||
|
nextCursor: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = useInfiniteQuery<PostsPage>({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: async ({ pageParam = 0 }): Promise<PostsPage> => {
|
||||||
|
const res = await fetch(`/api/posts?cursor=${pageParam}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// data.pages is typed as PostsPage[]
|
||||||
|
data?.pages.forEach((page) => {
|
||||||
|
// page is typed as PostsPage
|
||||||
|
page.posts.forEach((post) => {
|
||||||
|
// post is typed as Post
|
||||||
|
console.log(post.title);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infinite Query with Generics
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useInfiniteQuery<
|
||||||
|
TData, // Type of page data
|
||||||
|
TError, // Type of error
|
||||||
|
TQueryData, // Type of transformed data (from select)
|
||||||
|
TQueryKey, // Type of query key
|
||||||
|
TPageParam // Type of page parameter
|
||||||
|
>({ /* ... */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data } = useInfiniteQuery<
|
||||||
|
PostsPage,
|
||||||
|
ApiError,
|
||||||
|
PostsPage,
|
||||||
|
['posts', string],
|
||||||
|
number
|
||||||
|
>({
|
||||||
|
queryKey: ['posts', filter],
|
||||||
|
queryFn: async ({ pageParam }): Promise<PostsPage> => {
|
||||||
|
const res = await fetch(`/api/posts?cursor=${pageParam}&filter=${filter}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typing Select Transformations
|
||||||
|
|
||||||
|
### Transform Query Data
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface TodoApiResponse {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Todo {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
done: boolean; // renamed from completed
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async (): Promise<TodoApiResponse[]> => {
|
||||||
|
const res = await fetch('/api/todos');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
select: (data): Todo[] => {
|
||||||
|
// data is typed as TodoApiResponse[]
|
||||||
|
return data.map(todo => ({
|
||||||
|
id: todo.id,
|
||||||
|
title: todo.title,
|
||||||
|
done: todo.completed, // transform
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// data is now typed as Todo[] | undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
### Partial Selection
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['user', userId],
|
||||||
|
queryFn: async (): Promise<User> => {
|
||||||
|
const res = await fetch(`/api/users/${userId}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
select: (user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// data is typed as { id: number; name: string } | undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Hooks with Types
|
||||||
|
|
||||||
|
### Reusable Typed Hooks
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface UseTodoOptions {
|
||||||
|
refetchInterval?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTodo(id: number, options?: UseTodoOptions) {
|
||||||
|
return useQuery<Todo, ApiError>({
|
||||||
|
queryKey: ['todo', id],
|
||||||
|
queryFn: async (): Promise<Todo> => {
|
||||||
|
const res = await fetch(`/api/todos/${id}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const error: ApiError = await res.json();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
refetchInterval: options?.refetchInterval,
|
||||||
|
enabled: options?.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage with full type safety
|
||||||
|
const { data, error, isLoading } = useTodo(1, { refetchInterval: 5000 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generic Custom Hooks
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useResource<T>(resourceType: string, id: number) {
|
||||||
|
return useQuery<T, ApiError>({
|
||||||
|
queryKey: [resourceType, id],
|
||||||
|
queryFn: async (): Promise<T> => {
|
||||||
|
const res = await fetch(`/api/${resourceType}/${id}`);
|
||||||
|
if (!res.ok) throw await res.json();
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const { data: user } = useResource<User>('users', 1);
|
||||||
|
// data is typed as User | undefined
|
||||||
|
|
||||||
|
const { data: post } = useResource<Post>('posts', 123);
|
||||||
|
// data is typed as Post | undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typing Query Keys
|
||||||
|
|
||||||
|
### Const Query Keys
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const todoKeys = {
|
||||||
|
all: ['todos'] as const,
|
||||||
|
lists: () => [...todoKeys.all, 'list'] as const,
|
||||||
|
list: (filters: string) => [...todoKeys.lists(), filters] as const,
|
||||||
|
details: () => [...todoKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: number) => [...todoKeys.details(), id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type-safe query keys
|
||||||
|
useQuery({
|
||||||
|
queryKey: todoKeys.detail(todoId),
|
||||||
|
queryFn: () => fetchTodo(todoId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type-safe invalidation
|
||||||
|
queryClient.invalidateQueries({ queryKey: todoKeys.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: todoKeys.detail(todoId) });
|
||||||
|
```
|
||||||
|
|
||||||
|
### QueryKey Type Helper
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryKey } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
type TodoQueryKey = ['todos'] | ['todos', 'list', string] | ['todos', 'detail', number];
|
||||||
|
|
||||||
|
function useTodoQuery(key: TodoQueryKey) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: async () => {
|
||||||
|
// Implementation based on key
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typing Mutation Context
|
||||||
|
|
||||||
|
### Context with Optimistic Updates
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface UpdateTodoVariables {
|
||||||
|
id: number;
|
||||||
|
updates: Partial<Todo>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateTodoContext {
|
||||||
|
previousTodos: Todo[];
|
||||||
|
previousTodo: Todo;
|
||||||
|
rollback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation<
|
||||||
|
Todo,
|
||||||
|
ApiError,
|
||||||
|
UpdateTodoVariables,
|
||||||
|
UpdateTodoContext
|
||||||
|
>({
|
||||||
|
mutationFn: async ({ id, updates }) => {
|
||||||
|
const res = await fetch(`/api/todos/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onMutate: async ({ id, updates }): Promise<UpdateTodoContext> => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||||
|
|
||||||
|
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? [];
|
||||||
|
const previousTodo = queryClient.getQueryData<Todo>(['todo', id])!;
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
|
||||||
|
old?.map((todo) => (todo.id === id ? { ...todo, ...updates } : todo))
|
||||||
|
);
|
||||||
|
|
||||||
|
const rollback = () => {
|
||||||
|
queryClient.setQueryData(['todos'], previousTodos);
|
||||||
|
queryClient.setQueryData(['todo', id], previousTodo);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { previousTodos, previousTodo, rollback };
|
||||||
|
},
|
||||||
|
onError: (_error, _variables, context) => {
|
||||||
|
// context is typed as UpdateTodoContext | undefined
|
||||||
|
context?.rollback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type-Safe Query Options
|
||||||
|
|
||||||
|
### Shared Query Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { UseQueryOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
type TodoQueryOptions = UseQueryOptions<Todo, ApiError, Todo, ['todo', number]>;
|
||||||
|
|
||||||
|
const defaultTodoOptions: Partial<TodoQueryOptions> = {
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
retry: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
function useTodo(id: number, options?: Partial<TodoQueryOptions>) {
|
||||||
|
return useQuery<Todo, ApiError>({
|
||||||
|
queryKey: ['todo', id],
|
||||||
|
queryFn: () => fetchTodo(id),
|
||||||
|
...defaultTodoOptions,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Strict Type Safety
|
||||||
|
|
||||||
|
### Enable Strict Mode
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoid Type Assertions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Bad - using type assertion
|
||||||
|
const data = queryClient.getQueryData(['todos']) as Todo[];
|
||||||
|
|
||||||
|
// ✅ Good - proper type checking
|
||||||
|
const data = queryClient.getQueryData<Todo[]>(['todos']);
|
||||||
|
if (data) {
|
||||||
|
// TypeScript knows data is Todo[] here
|
||||||
|
console.log(data.length);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Non-Null Assertions
|
||||||
|
|
||||||
|
Use sparingly and only when you're certain:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Risky
|
||||||
|
const todos = queryClient.getQueryData<Todo[]>(['todos'])!;
|
||||||
|
|
||||||
|
// ✅ Better
|
||||||
|
const todos = queryClient.getQueryData<Todo[]>(['todos']);
|
||||||
|
if (!todos) {
|
||||||
|
throw new Error('Todos not found in cache');
|
||||||
|
}
|
||||||
|
// Now safe to use todos
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typing DevTools
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<YourApp />
|
||||||
|
{/* TypeScript will check props */}
|
||||||
|
<ReactQueryDevtools
|
||||||
|
initialIsOpen={false}
|
||||||
|
buttonPosition="bottom-right"
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Type Issues
|
||||||
|
|
||||||
|
### Issue: Cannot infer type from async function
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Problem
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch('/api/todos');
|
||||||
|
return res.json(); // Returns any
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Solution 1: Add return type to queryFn
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async (): Promise<Todo[]> => {
|
||||||
|
const res = await fetch('/api/todos');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Solution 2: Use generic parameter
|
||||||
|
const { data } = useQuery<Todo[]>({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch('/api/todos');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Context type mismatch
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Problem - context type doesn't match
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: updateTodo,
|
||||||
|
onMutate: () => {
|
||||||
|
return { previous: [] }; // Returns wrong type
|
||||||
|
},
|
||||||
|
onError: (err, vars, context) => {
|
||||||
|
context.previousTodos; // Error: previousTodos doesn't exist
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Solution - Define context interface
|
||||||
|
interface MutationContext {
|
||||||
|
previousTodos: Todo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation<Todo, Error, UpdateInput, MutationContext>({
|
||||||
|
mutationFn: updateTodo,
|
||||||
|
onMutate: async (): Promise<MutationContext> => {
|
||||||
|
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? [];
|
||||||
|
return { previousTodos };
|
||||||
|
},
|
||||||
|
onError: (err, vars, context) => {
|
||||||
|
if (context) {
|
||||||
|
context.previousTodos; // ✅ Properly typed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always type your query functions**
|
||||||
|
```tsx
|
||||||
|
queryFn: async (): Promise<Todo[]> => { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use type inference when possible**
|
||||||
|
```tsx
|
||||||
|
// Let TanStack Query infer types from queryFn return type
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async (): Promise<Todo[]> => fetchTodos(),
|
||||||
|
});
|
||||||
|
// data is automatically Todo[] | undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Define error types**
|
||||||
|
```tsx
|
||||||
|
const { error } = useQuery<Todo[], ApiError>({ /* ... */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Use const assertions for query keys**
|
||||||
|
```tsx
|
||||||
|
const todoKeys = {
|
||||||
|
all: ['todos'] as const,
|
||||||
|
detail: (id: number) => ['todos', id] as const,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Create reusable typed hooks**
|
||||||
|
```tsx
|
||||||
|
function useTodo(id: number) {
|
||||||
|
return useQuery<Todo, ApiError>({ /* ... */ });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Type mutation context for optimistic updates**
|
||||||
|
```tsx
|
||||||
|
useMutation<TData, TError, TVariables, TContext>({ /* ... */ })
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Use strict TypeScript settings**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"strict": true,
|
||||||
|
"strictNullChecks": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Avoid type assertions**
|
||||||
|
- Use type parameters instead
|
||||||
|
- Check for undefined/null before using data
|
||||||
Reference in New Issue
Block a user