generated from allagroup/nextjs-elysia-allaos
Initial commit
This commit is contained in:
472
.agents/skills/tanstack-query/SKILL.md
Normal file
472
.agents/skills/tanstack-query/SKILL.md
Normal file
@@ -0,0 +1,472 @@
|
||||
---
|
||||
name: tanstack-query
|
||||
description: TanStack Query v5 data fetching patterns including useSuspenseQuery, useQuery, mutations, cache management, and API service integration. Use when fetching data, managing server state, or working with TanStack Query hooks.
|
||||
---
|
||||
|
||||
# TanStack Query Patterns
|
||||
|
||||
## Purpose
|
||||
|
||||
Modern data fetching with TanStack Query v5 (latest: 5.90.5, November 2025), emphasizing Suspense-based queries, cache-first strategies, and centralized API services.
|
||||
|
||||
**Note**: v5 (released October 2023) has breaking changes from v4:
|
||||
|
||||
- `isLoading` → `isPending` for status
|
||||
- `cacheTime` → `gcTime` (garbage collection time)
|
||||
- React 18.0+ required
|
||||
- Callbacks removed from useQuery (onError, onSuccess, onSettled)
|
||||
- `keepPreviousData` replaced with `placeholderData` function
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Fetching data with TanStack Query
|
||||
- Using useSuspenseQuery or useQuery
|
||||
- Managing mutations
|
||||
- Cache invalidation and updates
|
||||
- API service patterns
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Primary Pattern: useSuspenseQuery
|
||||
|
||||
For **all new components**, use `useSuspenseQuery`:
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { postsApi } from '~/features/posts/api/postsApi';
|
||||
|
||||
function PostList() {
|
||||
const { data: posts } = useSuspenseQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: postsApi.getAll,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{posts.map(post => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap with Suspense
|
||||
<Suspense fallback={<PostsSkeleton />}>
|
||||
<PostList />
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No `isLoading` checks needed
|
||||
- Integrates with Suspense boundaries
|
||||
- Cleaner component code
|
||||
- Consistent loading UX
|
||||
|
||||
---
|
||||
|
||||
## useSuspenseQuery Patterns
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => userApi.get(userId),
|
||||
});
|
||||
|
||||
// data is never undefined - guaranteed by Suspense
|
||||
return <div>{data.name}</div>;
|
||||
```
|
||||
|
||||
### With Parameters
|
||||
|
||||
```typescript
|
||||
function UserPosts({ userId }: { userId: string }) {
|
||||
const { data: posts } = useSuspenseQuery({
|
||||
queryKey: ['users', userId, 'posts'],
|
||||
queryFn: () => postsApi.getByUser(userId),
|
||||
});
|
||||
|
||||
return <div>{posts.length} posts</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependent Queries
|
||||
|
||||
```typescript
|
||||
function PostDetails({ postId }: { postId: string }) {
|
||||
// First query
|
||||
const { data: post } = useSuspenseQuery({
|
||||
queryKey: ['posts', postId],
|
||||
queryFn: () => postsApi.get(postId),
|
||||
});
|
||||
|
||||
// Second query depends on first
|
||||
const { data: author } = useSuspenseQuery({
|
||||
queryKey: ['users', post.authorId],
|
||||
queryFn: () => userApi.get(post.authorId),
|
||||
});
|
||||
|
||||
return <div>{author.name} wrote {post.title}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## useQuery (Legacy Pattern)
|
||||
|
||||
Use `useQuery` only when you need loading/error states in the component:
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function Component() {
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: postsApi.getAll,
|
||||
});
|
||||
|
||||
if (isPending) return <Spinner />;
|
||||
if (error) return <Error error={error} />;
|
||||
|
||||
return <div>{data.map(...)}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**When to use `useQuery` vs `useSuspenseQuery`:**
|
||||
|
||||
- Use `useSuspenseQuery` by default (preferred)
|
||||
- Use `useQuery` only when you need component-level loading states
|
||||
- Most cases should use `useSuspenseQuery` + Suspense boundaries
|
||||
|
||||
---
|
||||
|
||||
## Mutations
|
||||
|
||||
### Basic Mutation
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
function CreatePostButton() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: postsApi.create,
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
mutation.mutate({
|
||||
title: 'New Post',
|
||||
content: 'Content here',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleCreate} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Creating...' : 'Create Post'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
```typescript
|
||||
const mutation = useMutation({
|
||||
mutationFn: postsApi.update,
|
||||
onMutate: async (updatedPost) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['posts', updatedPost.id] });
|
||||
|
||||
// Snapshot previous value
|
||||
const previousPost = queryClient.getQueryData(['posts', updatedPost.id]);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(['posts', updatedPost.id], updatedPost);
|
||||
|
||||
// Return context with snapshot
|
||||
return { previousPost };
|
||||
},
|
||||
onError: (err, updatedPost, context) => {
|
||||
// Rollback on error
|
||||
queryClient.setQueryData(['posts', updatedPost.id], context.previousPost);
|
||||
},
|
||||
onSettled: (data, error, variables) => {
|
||||
// Refetch after mutation
|
||||
queryClient.invalidateQueries({ queryKey: ['posts', variables.id] });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cache Management
|
||||
|
||||
### Invalidation
|
||||
|
||||
```typescript
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Invalidate all posts queries
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
|
||||
// Invalidate specific post
|
||||
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
|
||||
|
||||
// Invalidate all queries
|
||||
queryClient.invalidateQueries();
|
||||
```
|
||||
|
||||
### Manual Updates
|
||||
|
||||
```typescript
|
||||
// Update cache directly
|
||||
queryClient.setQueryData(['posts', postId], newPost);
|
||||
|
||||
// Update with function
|
||||
queryClient.setQueryData(['posts'], (oldPosts) => [...oldPosts, newPost]);
|
||||
```
|
||||
|
||||
### Prefetching
|
||||
|
||||
```typescript
|
||||
// Prefetch data
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: ['posts', postId],
|
||||
queryFn: () => postsApi.get(postId),
|
||||
});
|
||||
|
||||
// In a component
|
||||
const prefetchPost = (postId: string) => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['posts', postId],
|
||||
queryFn: () => postsApi.get(postId),
|
||||
});
|
||||
};
|
||||
|
||||
<Link
|
||||
to={`/posts/${post.id}`}
|
||||
onMouseEnter={() => prefetchPost(post.id)}
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Service Pattern
|
||||
|
||||
### Centralized API Service
|
||||
|
||||
```typescript
|
||||
// features/posts/api/postsApi.ts
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import type { Post, CreatePostDto, UpdatePostDto } from '~/types/post';
|
||||
|
||||
export const postsApi = {
|
||||
getAll: async (): Promise<Post[]> => {
|
||||
const response = await apiClient.get('/posts');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Post> => {
|
||||
const response = await apiClient.get(`/posts/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreatePostDto): Promise<Post> => {
|
||||
const response = await apiClient.post('/posts', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdatePostDto): Promise<Post> => {
|
||||
const response = await apiClient.put(`/posts/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/posts/${id}`);
|
||||
},
|
||||
|
||||
getByUser: async (userId: string): Promise<Post[]> => {
|
||||
const response = await apiClient.get(`/users/${userId}/posts`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Usage in Components
|
||||
|
||||
```typescript
|
||||
import { postsApi } from '~/features/posts/api/postsApi';
|
||||
|
||||
// In query
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: postsApi.getAll
|
||||
});
|
||||
|
||||
// In mutation
|
||||
const mutation = useMutation({
|
||||
mutationFn: postsApi.create
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Keys
|
||||
|
||||
### Key Structure
|
||||
|
||||
```typescript
|
||||
// List queries
|
||||
['posts'][('posts', { status: 'published' })][ // All posts // Filtered posts
|
||||
// Detail queries
|
||||
('posts', postId)
|
||||
][('posts', postId, 'comments')][ // Single post // Post comments
|
||||
// Nested resources
|
||||
('users', userId, 'posts')
|
||||
][('users', userId, 'posts', postId)]; // User's posts // Specific user post
|
||||
```
|
||||
|
||||
### Key Factories
|
||||
|
||||
```typescript
|
||||
// features/posts/api/postKeys.ts
|
||||
export const postKeys = {
|
||||
all: ['posts'] as const,
|
||||
lists: () => [...postKeys.all, 'list'] as const,
|
||||
list: (filters: string) => [...postKeys.lists(), { filters }] as const,
|
||||
details: () => [...postKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...postKeys.details(), id] as const,
|
||||
comments: (id: string) => [...postKeys.detail(id), 'comments'] as const
|
||||
};
|
||||
|
||||
// Usage
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: postKeys.detail(postId),
|
||||
queryFn: () => postsApi.get(postId)
|
||||
});
|
||||
|
||||
// Invalidate all post lists
|
||||
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### With Error Boundaries
|
||||
|
||||
```typescript
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
<ErrorBoundary fallback={<ErrorFallback />}>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<DataComponent />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
// In component
|
||||
function DataComponent() {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['data'],
|
||||
queryFn: fetchData,
|
||||
// Errors automatically caught by ErrorBoundary
|
||||
});
|
||||
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Retry and Cache Configuration
|
||||
|
||||
```typescript
|
||||
const { data } = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: postsApi.getAll,
|
||||
retry: 3, // Retry 3 times
|
||||
retryDelay: 1000, // Wait 1s between retries
|
||||
gcTime: 5 * 60 * 1000 // Garbage collection time: 5 minutes (v5: was 'cacheTime')
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Suspense by Default
|
||||
|
||||
```typescript
|
||||
// ✅ Good: useSuspenseQuery + Suspense
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<DataComponent />
|
||||
</Suspense>
|
||||
|
||||
function DataComponent() {
|
||||
const { data } = useSuspenseQuery({...});
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
|
||||
// ❌ Avoid: useQuery with manual loading
|
||||
function DataComponent() {
|
||||
const { data, isPending } = useQuery({...});
|
||||
if (isPending) return <Spinner />;
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Consistent Query Keys
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Use key factories
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: postKeys.detail(id),
|
||||
queryFn: () => postsApi.get(id)
|
||||
});
|
||||
|
||||
// ❌ Avoid: Inconsistent keys
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['post', id], // Different format
|
||||
queryFn: () => postsApi.get(id)
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Centralized API Services
|
||||
|
||||
```typescript
|
||||
// ✅ Good: API service
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: postsApi.getAll
|
||||
});
|
||||
|
||||
// ❌ Avoid: Inline fetching
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/posts');
|
||||
return res.json();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more patterns, see:
|
||||
|
||||
- [data-fetching.md](resources/data-fetching.md) - Advanced patterns
|
||||
- [cache-strategies.md](resources/cache-strategies.md) - Cache management
|
||||
- [mutation-patterns.md](resources/mutation-patterns.md) - Complex mutations
|
||||
252
.agents/skills/tanstack-query/resources/cache-strategies.md
Normal file
252
.agents/skills/tanstack-query/resources/cache-strategies.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Cache Management Strategies
|
||||
|
||||
## Cache Time Configuration
|
||||
|
||||
```typescript
|
||||
const { data } = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: fetchPosts,
|
||||
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
|
||||
gcTime: 10 * 60 * 1000 // Keep in cache for 10 minutes (formerly cacheTime)
|
||||
});
|
||||
```
|
||||
|
||||
## Cache Invalidation
|
||||
|
||||
### Invalidate Specific Queries
|
||||
|
||||
```typescript
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Invalidate all post queries
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
|
||||
// Invalidate specific post
|
||||
queryClient.invalidateQueries({ queryKey: ['post', postId] });
|
||||
|
||||
// Invalidate with exact match
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['posts'],
|
||||
exact: true // Only ['posts'], not ['posts', 'list']
|
||||
});
|
||||
```
|
||||
|
||||
### Invalidate on Mutation
|
||||
|
||||
```typescript
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: createPost,
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Manual Cache Updates
|
||||
|
||||
### Set Query Data
|
||||
|
||||
```typescript
|
||||
// Update cache directly
|
||||
queryClient.setQueryData(['post', postId], (oldData) => ({
|
||||
...oldData,
|
||||
title: 'New Title'
|
||||
}));
|
||||
|
||||
// Set new data
|
||||
queryClient.setQueryData(['post', postId], newPost);
|
||||
```
|
||||
|
||||
### Get Query Data
|
||||
|
||||
```typescript
|
||||
// Read from cache
|
||||
const cachedPost = queryClient.getQueryData(['post', postId]);
|
||||
|
||||
// Use in initialData
|
||||
const { data } = useQuery({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: () => fetchPost(postId),
|
||||
initialData: () => queryClient.getQueryData(['posts'])?.find((p) => p.id === postId)
|
||||
});
|
||||
```
|
||||
|
||||
## Refetch Strategies
|
||||
|
||||
### Refetch on Window Focus
|
||||
|
||||
```typescript
|
||||
const { data } = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: fetchPosts,
|
||||
refetchOnWindowFocus: true // Refetch when tab regains focus
|
||||
});
|
||||
```
|
||||
|
||||
### Refetch on Reconnect
|
||||
|
||||
```typescript
|
||||
const { data } = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: fetchPosts,
|
||||
refetchOnReconnect: true // Refetch when internet reconnects
|
||||
});
|
||||
```
|
||||
|
||||
### Refetch Intervals
|
||||
|
||||
```typescript
|
||||
const { data } = useQuery({
|
||||
queryKey: ['live-data'],
|
||||
queryFn: fetchLiveData,
|
||||
refetchInterval: 5000, // Refetch every 5 seconds
|
||||
refetchIntervalInBackground: false // Pause when tab not active
|
||||
});
|
||||
```
|
||||
|
||||
## Cache Persistence
|
||||
|
||||
### Persist to localStorage
|
||||
|
||||
```typescript
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 1000 * 60 * 60 * 24 // 24 hours
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const persister = createSyncStoragePersister({
|
||||
storage: window.localStorage
|
||||
});
|
||||
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persister={persister}
|
||||
>
|
||||
<App />
|
||||
</PersistQueryClientProvider>
|
||||
```
|
||||
|
||||
## Cache Deduplication
|
||||
|
||||
### Automatic Request Deduplication
|
||||
|
||||
```typescript
|
||||
// Both components will share the same request
|
||||
function Component1() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: fetchPosts
|
||||
});
|
||||
}
|
||||
|
||||
function Component2() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['posts'], // Same key = same request
|
||||
queryFn: fetchPosts
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Cache Preloading
|
||||
|
||||
### Prefetch Queries
|
||||
|
||||
```typescript
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Prefetch before navigation
|
||||
const handleMouseEnter = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: () => fetchPost(postId)
|
||||
});
|
||||
};
|
||||
|
||||
// Prefetch in loader
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: ['user', to.params.userId],
|
||||
queryFn: () => fetchUser(to.params.userId)
|
||||
});
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
### Ensure Query Data
|
||||
|
||||
```typescript
|
||||
// Fetch if not in cache, otherwise use cached
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: () => fetchPost(postId)
|
||||
});
|
||||
```
|
||||
|
||||
## Selective Cache Updates
|
||||
|
||||
### Update Nested Data
|
||||
|
||||
```typescript
|
||||
queryClient.setQueryData(['posts'], (oldPosts) => {
|
||||
return oldPosts.map((post) => (post.id === updatedPost.id ? updatedPost : post));
|
||||
});
|
||||
```
|
||||
|
||||
### Add to List Cache
|
||||
|
||||
```typescript
|
||||
// After creating a post
|
||||
queryClient.setQueryData(['posts'], (oldPosts = []) => {
|
||||
return [newPost, ...oldPosts];
|
||||
});
|
||||
```
|
||||
|
||||
### Remove from List Cache
|
||||
|
||||
```typescript
|
||||
// After deleting a post
|
||||
queryClient.setQueryData(['posts'], (oldPosts) => {
|
||||
return oldPosts.filter((post) => post.id !== deletedPostId);
|
||||
});
|
||||
```
|
||||
|
||||
## Cache Debugging
|
||||
|
||||
### React Query Devtools
|
||||
|
||||
```typescript
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
```
|
||||
|
||||
### Query Cache Events
|
||||
|
||||
```typescript
|
||||
const queryCache = queryClient.getQueryCache();
|
||||
|
||||
queryCache.subscribe((event) => {
|
||||
console.log('Query cache event:', event.type, event.query.queryKey);
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Set Appropriate staleTime** - Balance freshness vs performance
|
||||
2. **Use Invalidation Over Refetch** - Let queries refetch when needed
|
||||
3. **Prefetch Predictably** - Preload data on hover/intent
|
||||
4. **Update Cache on Mutations** - Keep UI in sync
|
||||
5. **Use Devtools** - Debug cache issues visually
|
||||
6. **Persist Important Data** - Save to localStorage for offline support
|
||||
7. **Deduplicate Requests** - Rely on automatic deduplication
|
||||
240
.agents/skills/tanstack-query/resources/data-fetching.md
Normal file
240
.agents/skills/tanstack-query/resources/data-fetching.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Advanced Data Fetching Patterns with TanStack Query
|
||||
|
||||
## Dependent Queries
|
||||
|
||||
Queries that depend on data from other queries:
|
||||
|
||||
```typescript
|
||||
// First query - Get user ID
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: fetchCurrentUser
|
||||
});
|
||||
|
||||
// Second query - Depends on user ID
|
||||
const { data: posts } = useQuery({
|
||||
queryKey: ['posts', user?.id],
|
||||
queryFn: () => fetchUserPosts(user!.id),
|
||||
enabled: !!user // Only run when user is available
|
||||
});
|
||||
```
|
||||
|
||||
## Parallel Queries
|
||||
|
||||
Fetch multiple independent queries simultaneously:
|
||||
|
||||
```typescript
|
||||
function Dashboard() {
|
||||
const queries = useQueries({
|
||||
queries: [
|
||||
{ queryKey: ['stats'], queryFn: fetchStats },
|
||||
{ queryKey: ['recentPosts'], queryFn: fetchRecentPosts },
|
||||
{ queryKey: ['notifications'], queryFn: fetchNotifications }
|
||||
]
|
||||
});
|
||||
|
||||
const [statsQuery, postsQuery, notificationsQuery] = queries;
|
||||
|
||||
if (queries.some(q => q.isLoading)) return <Loading />;
|
||||
|
||||
return <Dashboard
|
||||
stats={statsQuery.data}
|
||||
posts={postsQuery.data}
|
||||
notifications={notificationsQuery.data}
|
||||
/>;
|
||||
}
|
||||
```
|
||||
|
||||
## Infinite Queries
|
||||
|
||||
For pagination and infinite scroll:
|
||||
|
||||
```typescript
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
|
||||
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
|
||||
initialPageParam: 1
|
||||
});
|
||||
|
||||
// Flatten pages
|
||||
const allPosts = data?.pages.flatMap(page => page.posts) ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{allPosts.map(post => <PostCard key={post.id} post={post} />)}
|
||||
{hasNextPage && (
|
||||
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
|
||||
{isFetchingNextPage ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## Prefetching
|
||||
|
||||
Preload data before it's needed:
|
||||
|
||||
```typescript
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
function PostLink({ postId }: { postId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: () => fetchPost(postId)
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={`/posts/${postId}`} onMouseEnter={handleMouseEnter}>
|
||||
View Post
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Suspense Mode
|
||||
|
||||
Use with React Suspense:
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
|
||||
function PostDetails({ postId }: { postId: string }) {
|
||||
// Throws promise on loading, error on error
|
||||
const { data: post } = useSuspenseQuery({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: () => fetchPost(postId)
|
||||
});
|
||||
|
||||
return <div>{post.title}</div>;
|
||||
}
|
||||
|
||||
// Wrap with Suspense
|
||||
<Suspense fallback={<Loading />}>
|
||||
<PostDetails postId="123" />
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
## Query Cancellation
|
||||
|
||||
Cancel queries when component unmounts:
|
||||
|
||||
```typescript
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['search', searchTerm],
|
||||
queryFn: async ({ signal }) => {
|
||||
const response = await fetch(`/api/search?q=${searchTerm}`, { signal });
|
||||
return response.json();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Initial Data
|
||||
|
||||
Provide initial data to avoid loading state:
|
||||
|
||||
```typescript
|
||||
const { data } = useQuery({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: () => fetchPost(postId),
|
||||
initialData: () => {
|
||||
// Get from cache or other source
|
||||
return queryClient.getQueryData(['posts'])?.find((post) => post.id === postId);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Placeholder Data
|
||||
|
||||
Show placeholder while loading:
|
||||
|
||||
```typescript
|
||||
const { data, isPlaceholderData } = useQuery({
|
||||
queryKey: ['posts', page],
|
||||
queryFn: () => fetchPosts(page),
|
||||
placeholderData: (previousData) => previousData // Keep previous page while loading
|
||||
});
|
||||
|
||||
// Or provide static placeholder
|
||||
placeholderData: { posts: [], total: 0 }
|
||||
```
|
||||
|
||||
## Optimistic Updates with Queries
|
||||
|
||||
Update UI immediately, rollback on error:
|
||||
|
||||
```typescript
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: updatePost,
|
||||
onMutate: async (newPost) => {
|
||||
// Cancel outgoing queries
|
||||
await queryClient.cancelQueries({ queryKey: ['post', newPost.id] });
|
||||
|
||||
// Snapshot current value
|
||||
const previousPost = queryClient.getQueryData(['post', newPost.id]);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(['post', newPost.id], newPost);
|
||||
|
||||
return { previousPost };
|
||||
},
|
||||
onError: (err, newPost, context) => {
|
||||
// Rollback on error
|
||||
queryClient.setQueryData(['post', newPost.id], context?.previousPost);
|
||||
},
|
||||
onSettled: (newPost) => {
|
||||
// Refetch after success or error
|
||||
queryClient.invalidateQueries({ queryKey: ['post', newPost.id] });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Query Retries
|
||||
|
||||
Configure retry behavior:
|
||||
|
||||
```typescript
|
||||
const { data } = useQuery({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: () => fetchPost(postId),
|
||||
retry: 3, // Retry 3 times
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) // Exponential backoff
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Handle query errors:
|
||||
|
||||
```typescript
|
||||
const { data, error, isError } = useQuery({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: () => fetchPost(postId),
|
||||
throwOnError: false // Don't throw, just set error
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return <ErrorMessage error={error} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Suspense** - Better loading UX with React Suspense
|
||||
2. **Prefetch on Intent** - Preload data on hover/focus
|
||||
3. **Enable Queries Conditionally** - Use `enabled` option
|
||||
4. **Cancel on Unmount** - Use abort signals
|
||||
5. **Handle Errors Gracefully** - Show error states
|
||||
6. **Optimize with Placeholders** - Show previous data while loading
|
||||
344
.agents/skills/tanstack-query/resources/mutation-patterns.md
Normal file
344
.agents/skills/tanstack-query/resources/mutation-patterns.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# Complex Mutation Patterns
|
||||
|
||||
## Basic Mutations
|
||||
|
||||
```typescript
|
||||
const { mutate, isPending, isError, error } = useMutation({
|
||||
mutationFn: (newPost: CreatePostDto) => createPost(newPost),
|
||||
onSuccess: (data) => {
|
||||
console.log('Post created:', data);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create post:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger mutation
|
||||
mutate({ title: 'New Post', content: '...' });
|
||||
```
|
||||
|
||||
## Optimistic Updates
|
||||
|
||||
Update UI immediately, rollback on error:
|
||||
|
||||
```typescript
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: updatePost,
|
||||
onMutate: async (newPost) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['posts'] });
|
||||
|
||||
// Snapshot previous value
|
||||
const previousPosts = queryClient.getQueryData(['posts']);
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData(['posts'], (old) =>
|
||||
old.map((post) => (post.id === newPost.id ? newPost : post))
|
||||
);
|
||||
|
||||
// Return context with snapshot
|
||||
return { previousPosts };
|
||||
},
|
||||
onError: (err, newPost, context) => {
|
||||
// Rollback to previous value
|
||||
queryClient.setQueryData(['posts'], context.previousPosts);
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always refetch after error or success
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Sequential Mutations
|
||||
|
||||
Run mutations in sequence:
|
||||
|
||||
```typescript
|
||||
const createAndPublish = async (postData) => {
|
||||
// Create post
|
||||
const post = await createPostMutation.mutateAsync(postData);
|
||||
|
||||
// Publish post
|
||||
const published = await publishPostMutation.mutateAsync(post.id);
|
||||
|
||||
return published;
|
||||
};
|
||||
```
|
||||
|
||||
## Parallel Mutations
|
||||
|
||||
Run multiple mutations simultaneously:
|
||||
|
||||
```typescript
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async (updates) => {
|
||||
const results = await Promise.all([
|
||||
updateProfile(updates.profile),
|
||||
updateSettings(updates.settings),
|
||||
updatePreferences(updates.preferences)
|
||||
]);
|
||||
return results;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Mutation with Invalidation
|
||||
|
||||
```typescript
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: createPost,
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
|
||||
// Or update cache directly
|
||||
queryClient.setQueryData(['posts'], (old) => [newPost, ...old]);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Mutation with Multiple Cache Updates
|
||||
|
||||
```typescript
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: deletePost,
|
||||
onSuccess: (_, deletedPostId) => {
|
||||
// Update posts list
|
||||
queryClient.setQueryData(['posts'], (old) => old.filter((post) => post.id !== deletedPostId));
|
||||
|
||||
// Update post count
|
||||
queryClient.setQueryData(['postsCount'], (old) => old - 1);
|
||||
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({ queryKey: ['user', 'stats'] });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
const { mutate, isError, error, reset } = useMutation({
|
||||
mutationFn: createPost,
|
||||
onError: (error) => {
|
||||
if (error.code === 'VALIDATION_ERROR') {
|
||||
setFormErrors(error.fields);
|
||||
} else if (error.code === 'NETWORK_ERROR') {
|
||||
showRetryDialog();
|
||||
} else {
|
||||
showGenericError();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clear error state
|
||||
reset();
|
||||
```
|
||||
|
||||
## Retry Failed Mutations
|
||||
|
||||
```typescript
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: createPost,
|
||||
retry: 3, // Retry 3 times on failure
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) // Exponential backoff
|
||||
});
|
||||
```
|
||||
|
||||
## Mutation with Loading State
|
||||
|
||||
```typescript
|
||||
function CreatePostForm() {
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: createPost,
|
||||
onSuccess: () => {
|
||||
navigate('/posts');
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data) => {
|
||||
mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* form fields */}
|
||||
<button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Creating...' : 'Create Post'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation with Variables
|
||||
|
||||
```typescript
|
||||
const { mutate, variables } = useMutation({
|
||||
mutationFn: updatePost
|
||||
});
|
||||
|
||||
// Access last mutation variables
|
||||
console.log('Last updated post:', variables);
|
||||
```
|
||||
|
||||
## Mutation Callbacks
|
||||
|
||||
```typescript
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: createPost,
|
||||
onMutate: (variables) => {
|
||||
console.log('Starting mutation with:', variables);
|
||||
},
|
||||
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 (success or error)');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Mutation with Form Integration
|
||||
|
||||
```typescript
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
function CreatePostForm() {
|
||||
const { register, handleSubmit, reset } = useForm();
|
||||
|
||||
const { mutate, isPending, isError, error } = useMutation({
|
||||
mutationFn: createPost,
|
||||
onSuccess: () => {
|
||||
reset(); // Clear form
|
||||
toast.success('Post created!');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register('title')} />
|
||||
<textarea {...register('content')} />
|
||||
<button type="submit" disabled={isPending}>
|
||||
Submit
|
||||
</button>
|
||||
{isError && <ErrorMessage error={error} />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation State Reset
|
||||
|
||||
```typescript
|
||||
const { mutate, data, error, reset } = useMutation({
|
||||
mutationFn: createPost
|
||||
});
|
||||
|
||||
// Clear mutation state
|
||||
const handleReset = () => {
|
||||
reset(); // Clears data, error, status, etc.
|
||||
};
|
||||
```
|
||||
|
||||
## Global Mutation Configuration
|
||||
|
||||
```typescript
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
onError: (error) => {
|
||||
// Global error handler
|
||||
console.error('Mutation error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Mutation Lifecycle
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ idle │
|
||||
└──────┬──────┘
|
||||
│ mutate()
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ pending │ ─── onMutate()
|
||||
└──────┬──────┘
|
||||
│
|
||||
├─ success ──► onSuccess() ──┐
|
||||
│ │
|
||||
└─ error ────► onError() ────┤
|
||||
│
|
||||
▼
|
||||
onSettled()
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Optimistic Updates** - Better UX for fast operations
|
||||
2. **Always Handle Errors** - Show clear error messages
|
||||
3. **Invalidate Related Queries** - Keep cache in sync
|
||||
4. **Use onSettled** - For cleanup that runs regardless of success/error
|
||||
5. **Reset on Unmount** - Clear mutation state when component unmounts
|
||||
6. **Retry Network Errors** - Configure retry for transient failures
|
||||
7. **Show Loading States** - Disable buttons during mutation
|
||||
8. **Rollback on Error** - Revert optimistic updates if mutation fails
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Create with Redirect
|
||||
|
||||
```typescript
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: createPost,
|
||||
onSuccess: (newPost) => {
|
||||
navigate(`/posts/${newPost.id}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Update with Toast
|
||||
|
||||
```typescript
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: updatePost,
|
||||
onSuccess: () => {
|
||||
toast.success('Post updated!');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update post');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Delete with Confirmation
|
||||
|
||||
```typescript
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: deletePost,
|
||||
onMutate: async () => {
|
||||
const confirmed = await confirm('Are you sure?');
|
||||
if (!confirmed) throw new Error('Cancelled');
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Post deleted');
|
||||
navigate('/posts');
|
||||
}
|
||||
});
|
||||
```
|
||||
86
.agents/skills/tanstack-query/skill-rules-fragment.json
Normal file
86
.agents/skills/tanstack-query/skill-rules-fragment.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"tanstack-query": {
|
||||
"type": "domain",
|
||||
"enforcement": "suggest",
|
||||
"priority": "high",
|
||||
"promptTriggers": {
|
||||
"keywords": [
|
||||
"tanstack query",
|
||||
"react query",
|
||||
"@tanstack/react-query",
|
||||
"useQuery",
|
||||
"useMutation",
|
||||
"useSuspenseQuery",
|
||||
"useInfiniteQuery",
|
||||
"useQueries",
|
||||
"useQueryClient",
|
||||
"useIsFetching",
|
||||
"useIsMutating",
|
||||
"useMutationState",
|
||||
"QueryClient",
|
||||
"QueryClientProvider",
|
||||
"queryKey",
|
||||
"queryFn",
|
||||
"mutationFn",
|
||||
"invalidateQueries",
|
||||
"setQueryData",
|
||||
"getQueryData",
|
||||
"removeQueries",
|
||||
"resetQueries",
|
||||
"staleTime",
|
||||
"cacheTime",
|
||||
"refetchInterval",
|
||||
"refetchOnWindowFocus",
|
||||
"refetchOnReconnect",
|
||||
"isLoading",
|
||||
"isError",
|
||||
"isFetching",
|
||||
"isSuccess",
|
||||
"queryCache",
|
||||
"mutationCache"
|
||||
],
|
||||
"intentPatterns": [
|
||||
"use.*tanstack.*query",
|
||||
"use.*react.*query",
|
||||
"create.*tanstack.*(query|mutation)",
|
||||
"invalidate.*query.*cache",
|
||||
"invalidate.*tanstack.*queries",
|
||||
"prefetch.*query",
|
||||
"setup.*query.*client",
|
||||
"configure.*tanstack.*query",
|
||||
"use.*(useQuery|useMutation|useInfiniteQuery)",
|
||||
"implement.*query.*invalidation",
|
||||
"add.*query.*key",
|
||||
"set.*query.*data"
|
||||
]
|
||||
},
|
||||
"fileTriggers": {
|
||||
"pathPatterns": [
|
||||
"**/api/**/*.ts",
|
||||
"**/api/**/*.tsx",
|
||||
"**/queries/**/*.ts",
|
||||
"**/queries/**/*.tsx",
|
||||
"**/hooks/**/*.ts",
|
||||
"**/hooks/**/*.tsx",
|
||||
"**/mutations/**/*.ts",
|
||||
"**/mutations/**/*.tsx"
|
||||
],
|
||||
"contentPatterns": [
|
||||
"useQuery\\(",
|
||||
"useMutation\\(",
|
||||
"useSuspenseQuery\\(",
|
||||
"useInfiniteQuery\\(",
|
||||
"useQueryClient\\(",
|
||||
"queryClient\\.",
|
||||
"import.*@tanstack/react-query",
|
||||
"from '@tanstack/react-query'",
|
||||
"QueryClientProvider",
|
||||
"new QueryClient\\(",
|
||||
"invalidateQueries\\(",
|
||||
"setQueryData\\(",
|
||||
"queryKey:",
|
||||
"queryFn:"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user