init
This commit is contained in:
767
.opencode/skills/frontend-development/resources/data-fetching.md
Normal file
767
.opencode/skills/frontend-development/resources/data-fetching.md
Normal file
@@ -0,0 +1,767 @@
|
||||
# Data Fetching Patterns
|
||||
|
||||
Modern data fetching using TanStack Query with Suspense boundaries, cache-first strategies, and centralized API services.
|
||||
|
||||
---
|
||||
|
||||
## PRIMARY PATTERN: useSuspenseQuery
|
||||
|
||||
### Why useSuspenseQuery?
|
||||
|
||||
For **all new components**, use `useSuspenseQuery` instead of regular `useQuery`:
|
||||
|
||||
**Benefits:**
|
||||
- No `isLoading` checks needed
|
||||
- Integrates with Suspense boundaries
|
||||
- Cleaner component code
|
||||
- Consistent loading UX
|
||||
- Better error handling with error boundaries
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { myFeatureApi } from '../api/myFeatureApi';
|
||||
|
||||
export const MyComponent: React.FC<Props> = ({ id }) => {
|
||||
// No isLoading - Suspense handles it!
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['myEntity', id],
|
||||
queryFn: () => myFeatureApi.getEntity(id),
|
||||
});
|
||||
|
||||
// data is ALWAYS defined here (not undefined | Data)
|
||||
return <div>{data.name}</div>;
|
||||
};
|
||||
|
||||
// Wrap in Suspense boundary
|
||||
<SuspenseLoader>
|
||||
<MyComponent id={123} />
|
||||
</SuspenseLoader>
|
||||
```
|
||||
|
||||
### useSuspenseQuery vs useQuery
|
||||
|
||||
| Feature | useSuspenseQuery | useQuery |
|
||||
|---------|------------------|----------|
|
||||
| Loading state | Handled by Suspense | Manual `isLoading` check |
|
||||
| Data type | Always defined | `Data \| undefined` |
|
||||
| Use with | Suspense boundaries | Traditional components |
|
||||
| Recommended for | **NEW components** | Legacy code only |
|
||||
| Error handling | Error boundaries | Manual error state |
|
||||
|
||||
**When to use regular useQuery:**
|
||||
- Maintaining legacy code
|
||||
- Very simple cases without Suspense
|
||||
- Polling with background updates
|
||||
|
||||
**For new components: Always prefer useSuspenseQuery**
|
||||
|
||||
---
|
||||
|
||||
## Cache-First Strategy
|
||||
|
||||
### Cache-First Pattern Example
|
||||
|
||||
**Smart caching** reduces API calls by checking React Query cache first:
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { postApi } from '../api/postApi';
|
||||
|
||||
export function useSuspensePost(postId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useSuspenseQuery({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: async () => {
|
||||
// Strategy 1: Try to get from list cache first
|
||||
const cachedListData = queryClient.getQueryData<{ posts: Post[] }>([
|
||||
'posts',
|
||||
'list'
|
||||
]);
|
||||
|
||||
if (cachedListData?.posts) {
|
||||
const cachedPost = cachedListData.posts.find(
|
||||
(post) => post.id === postId
|
||||
);
|
||||
|
||||
if (cachedPost) {
|
||||
return cachedPost; // Return from cache!
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Not in cache, fetch from API
|
||||
return postApi.getPost(postId);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch on focus
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Check grid/list cache before API call
|
||||
- Avoids redundant requests
|
||||
- `staleTime`: How long data is considered fresh
|
||||
- `gcTime`: How long unused data stays in cache
|
||||
- `refetchOnWindowFocus: false`: User preference
|
||||
|
||||
---
|
||||
|
||||
## Parallel Data Fetching
|
||||
|
||||
### useSuspenseQueries
|
||||
|
||||
When fetching multiple independent resources:
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQueries } from '@tanstack/react-query';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const [userQuery, settingsQuery, preferencesQuery] = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['user'],
|
||||
queryFn: () => userApi.getCurrentUser(),
|
||||
},
|
||||
{
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => settingsApi.getSettings(),
|
||||
},
|
||||
{
|
||||
queryKey: ['preferences'],
|
||||
queryFn: () => preferencesApi.getPreferences(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// All data available, Suspense handles loading
|
||||
const user = userQuery.data;
|
||||
const settings = settingsQuery.data;
|
||||
const preferences = preferencesQuery.data;
|
||||
|
||||
return <Display user={user} settings={settings} prefs={preferences} />;
|
||||
};
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- All queries in parallel
|
||||
- Single Suspense boundary
|
||||
- Type-safe results
|
||||
|
||||
---
|
||||
|
||||
## Query Keys Organization
|
||||
|
||||
### Naming Convention
|
||||
|
||||
```typescript
|
||||
// Entity list
|
||||
['entities', blogId]
|
||||
['entities', blogId, 'summary'] // With view mode
|
||||
['entities', blogId, 'flat']
|
||||
|
||||
// Single entity
|
||||
['entity', blogId, entityId]
|
||||
|
||||
// Related data
|
||||
['entity', entityId, 'history']
|
||||
['entity', entityId, 'comments']
|
||||
|
||||
// User-specific
|
||||
['user', userId, 'profile']
|
||||
['user', userId, 'permissions']
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Start with entity name (plural for lists, singular for one)
|
||||
- Include IDs for specificity
|
||||
- Add view mode / relationship at end
|
||||
- Consistent across app
|
||||
|
||||
### Query Key Examples
|
||||
|
||||
```typescript
|
||||
// From useSuspensePost.ts
|
||||
queryKey: ['post', blogId, postId]
|
||||
queryKey: ['posts-v2', blogId, 'summary']
|
||||
|
||||
// Invalidation patterns
|
||||
queryClient.invalidateQueries({ queryKey: ['post', blogId] }); // All posts for form
|
||||
queryClient.invalidateQueries({ queryKey: ['post'] }); // All posts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Service Layer Pattern
|
||||
|
||||
### File Structure
|
||||
|
||||
Create centralized API service per feature:
|
||||
|
||||
```
|
||||
features/
|
||||
my-feature/
|
||||
api/
|
||||
myFeatureApi.ts # Service layer
|
||||
```
|
||||
|
||||
### Service Pattern (from postApi.ts)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Centralized API service for my-feature operations
|
||||
* Uses apiClient for consistent error handling
|
||||
*/
|
||||
import apiClient from '@/lib/apiClient';
|
||||
import type { MyEntity, UpdatePayload } from '../types';
|
||||
|
||||
export const myFeatureApi = {
|
||||
/**
|
||||
* Fetch a single entity
|
||||
*/
|
||||
getEntity: async (blogId: number, entityId: number): Promise<MyEntity> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/blog/entities/${blogId}/${entityId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all entities for a form
|
||||
*/
|
||||
getEntities: async (blogId: number, view: 'summary' | 'flat'): Promise<MyEntity[]> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/blog/entities/${blogId}`,
|
||||
{ params: { view } }
|
||||
);
|
||||
return data.rows;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update entity
|
||||
*/
|
||||
updateEntity: async (
|
||||
blogId: number,
|
||||
entityId: number,
|
||||
payload: UpdatePayload
|
||||
): Promise<MyEntity> => {
|
||||
const { data } = await apiClient.put(
|
||||
`/blog/entities/${blogId}/${entityId}`,
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete entity
|
||||
*/
|
||||
deleteEntity: async (blogId: number, entityId: number): Promise<void> => {
|
||||
await apiClient.delete(`/blog/entities/${blogId}/${entityId}`);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Export single object with methods
|
||||
- Use `apiClient` (axios instance from `@/lib/apiClient`)
|
||||
- Type-safe parameters and returns
|
||||
- JSDoc comments for each method
|
||||
- Centralized error handling (apiClient handles it)
|
||||
|
||||
---
|
||||
|
||||
## Route Format Rules (IMPORTANT)
|
||||
|
||||
### Correct Format
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Direct service path
|
||||
await apiClient.get('/blog/posts/123');
|
||||
await apiClient.post('/projects/create', data);
|
||||
await apiClient.put('/users/update/456', updates);
|
||||
await apiClient.get('/email/templates');
|
||||
|
||||
// ❌ WRONG - Do NOT add /api/ prefix
|
||||
await apiClient.get('/api/blog/posts/123'); // WRONG!
|
||||
await apiClient.post('/api/projects/create', data); // WRONG!
|
||||
```
|
||||
|
||||
**Microservice Routing:**
|
||||
- Form service: `/blog/*`
|
||||
- Projects service: `/projects/*`
|
||||
- Email service: `/email/*`
|
||||
- Users service: `/users/*`
|
||||
|
||||
**Why:** API routing is handled by proxy configuration, no `/api/` prefix needed.
|
||||
|
||||
---
|
||||
|
||||
## Mutations
|
||||
|
||||
### Basic Mutation Pattern
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { myFeatureApi } from '../api/myFeatureApi';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: UpdatePayload) =>
|
||||
myFeatureApi.updateEntity(blogId, entityId, payload),
|
||||
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['entity', blogId, entityId]
|
||||
});
|
||||
showSuccess('Entity updated successfully');
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
showError('Failed to update entity');
|
||||
console.error('Update error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdate = () => {
|
||||
updateMutation.mutate({ name: 'New Name' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
```typescript
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload) => myFeatureApi.update(id, payload),
|
||||
|
||||
// Optimistic update
|
||||
onMutate: async (newData) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['entity', id] });
|
||||
|
||||
// Snapshot current value
|
||||
const previousData = queryClient.getQueryData(['entity', id]);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(['entity', id], (old) => ({
|
||||
...old,
|
||||
...newData,
|
||||
}));
|
||||
|
||||
// Return rollback function
|
||||
return { previousData };
|
||||
},
|
||||
|
||||
// Rollback on error
|
||||
onError: (err, newData, context) => {
|
||||
queryClient.setQueryData(['entity', id], context.previousData);
|
||||
showError('Update failed');
|
||||
},
|
||||
|
||||
// Refetch after success or error
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['entity', id] });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Query Patterns
|
||||
|
||||
### Prefetching
|
||||
|
||||
```typescript
|
||||
export function usePrefetchEntity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (blogId: number, entityId: number) => {
|
||||
return queryClient.prefetchQuery({
|
||||
queryKey: ['entity', blogId, entityId],
|
||||
queryFn: () => myFeatureApi.getEntity(blogId, entityId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Usage: Prefetch on hover
|
||||
<div onMouseEnter={() => prefetch(blogId, id)}>
|
||||
<Link to={`/entity/${id}`}>View</Link>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Cache Access Without Fetching
|
||||
|
||||
```typescript
|
||||
export function useEntityFromCache(blogId: number, entityId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get from cache, don't fetch if missing
|
||||
const directCache = queryClient.getQueryData<MyEntity>(['entity', blogId, entityId]);
|
||||
|
||||
if (directCache) return directCache;
|
||||
|
||||
// Try grid cache
|
||||
const gridCache = queryClient.getQueryData<{ rows: MyEntity[] }>(['entities-v2', blogId]);
|
||||
|
||||
return gridCache?.rows.find(row => row.id === entityId);
|
||||
}
|
||||
```
|
||||
|
||||
### Dependent Queries
|
||||
|
||||
```typescript
|
||||
// Fetch user first, then user's settings
|
||||
const { data: user } = useSuspenseQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => userApi.getUser(userId),
|
||||
});
|
||||
|
||||
const { data: settings } = useSuspenseQuery({
|
||||
queryKey: ['user', userId, 'settings'],
|
||||
queryFn: () => settingsApi.getUserSettings(user.id),
|
||||
// Automatically waits for user to load due to Suspense
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Client Configuration
|
||||
|
||||
### Using apiClient
|
||||
|
||||
```typescript
|
||||
import apiClient from '@/lib/apiClient';
|
||||
|
||||
// apiClient is a configured axios instance
|
||||
// Automatically includes:
|
||||
// - Base URL configuration
|
||||
// - Cookie-based authentication
|
||||
// - Error interceptors
|
||||
// - Response transformers
|
||||
```
|
||||
|
||||
**Do NOT create new axios instances** - use apiClient for consistency.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling in Queries
|
||||
|
||||
### onError Callback
|
||||
|
||||
```typescript
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
const { showError } = useMuiSnackbar();
|
||||
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['entity', id],
|
||||
queryFn: () => myFeatureApi.getEntity(id),
|
||||
|
||||
// Handle errors
|
||||
onError: (error) => {
|
||||
showError('Failed to load entity');
|
||||
console.error('Load error:', error);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Error Boundaries
|
||||
|
||||
Combine with Error Boundaries for comprehensive error handling:
|
||||
|
||||
```typescript
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
<ErrorBoundary
|
||||
fallback={<ErrorDisplay />}
|
||||
onError={(error) => console.error(error)}
|
||||
>
|
||||
<SuspenseLoader>
|
||||
<ComponentWithSuspenseQuery />
|
||||
</SuspenseLoader>
|
||||
</ErrorBoundary>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Example 1: Simple Entity Fetch
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { userApi } from '../api/userApi';
|
||||
|
||||
interface UserProfileProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const UserProfile: React.FC<UserProfileProps> = ({ userId }) => {
|
||||
const { data: user } = useSuspenseQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => userApi.getUser(userId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant='h5'>{user.name}</Typography>
|
||||
<Typography>{user.email}</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Usage with Suspense
|
||||
<SuspenseLoader>
|
||||
<UserProfile userId='123' />
|
||||
</SuspenseLoader>
|
||||
```
|
||||
|
||||
### Example 2: Cache-First Strategy
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { postApi } from '../api/postApi';
|
||||
import type { Post } from '../types';
|
||||
|
||||
/**
|
||||
* Hook with cache-first strategy
|
||||
* Checks grid cache before API call
|
||||
*/
|
||||
export function useSuspensePost(blogId: number, postId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useSuspenseQuery<Post, Error>({
|
||||
queryKey: ['post', blogId, postId],
|
||||
queryFn: async () => {
|
||||
// 1. Check grid cache first
|
||||
const gridCache = queryClient.getQueryData<{ rows: Post[] }>([
|
||||
'posts-v2',
|
||||
blogId,
|
||||
'summary'
|
||||
]) || queryClient.getQueryData<{ rows: Post[] }>([
|
||||
'posts-v2',
|
||||
blogId,
|
||||
'flat'
|
||||
]);
|
||||
|
||||
if (gridCache?.rows) {
|
||||
const cached = gridCache.rows.find(row => row.S_ID === postId);
|
||||
if (cached) {
|
||||
return cached; // Reuse grid data
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Not in cache, fetch directly
|
||||
return postApi.getPost(blogId, postId);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Avoids duplicate API calls
|
||||
- Instant data if already loaded
|
||||
- Falls back to API if not cached
|
||||
|
||||
### Example 3: Parallel Fetching
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQueries } from '@tanstack/react-query';
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const [statsQuery, projectsQuery, notificationsQuery] = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['stats'],
|
||||
queryFn: () => statsApi.getStats(),
|
||||
},
|
||||
{
|
||||
queryKey: ['projects', 'active'],
|
||||
queryFn: () => projectsApi.getActiveProjects(),
|
||||
},
|
||||
{
|
||||
queryKey: ['notifications', 'unread'],
|
||||
queryFn: () => notificationsApi.getUnread(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<StatsCard data={statsQuery.data} />
|
||||
<ProjectsList projects={projectsQuery.data} />
|
||||
<Notifications items={notificationsQuery.data} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mutations with Cache Invalidation
|
||||
|
||||
### Update Mutation
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { postApi } from '../api/postApi';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
export const useUpdatePost = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ blogId, postId, data }: UpdateParams) =>
|
||||
postApi.updatePost(blogId, postId, data),
|
||||
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate specific post
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['post', variables.blogId, variables.postId]
|
||||
});
|
||||
|
||||
// Invalidate list to refresh grid
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['posts-v2', variables.blogId]
|
||||
});
|
||||
|
||||
showSuccess('Post updated');
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
showError('Failed to update post');
|
||||
console.error('Update error:', error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
const updatePost = useUpdatePost();
|
||||
|
||||
const handleSave = () => {
|
||||
updatePost.mutate({
|
||||
blogId: 123,
|
||||
postId: 456,
|
||||
data: { responses: { '101': 'value' } }
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Delete Mutation
|
||||
|
||||
```typescript
|
||||
export const useDeletePost = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ blogId, postId }: DeleteParams) =>
|
||||
postApi.deletePost(blogId, postId),
|
||||
|
||||
onSuccess: (data, variables) => {
|
||||
// Remove from cache manually (optimistic)
|
||||
queryClient.setQueryData<{ rows: Post[] }>(
|
||||
['posts-v2', variables.blogId],
|
||||
(old) => ({
|
||||
...old,
|
||||
rows: old?.rows.filter(row => row.S_ID !== variables.postId) || []
|
||||
})
|
||||
);
|
||||
|
||||
showSuccess('Post deleted');
|
||||
},
|
||||
|
||||
onError: (error, variables) => {
|
||||
// Rollback - refetch to get accurate state
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['posts-v2', variables.blogId]
|
||||
});
|
||||
showError('Failed to delete post');
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Configuration Best Practices
|
||||
|
||||
### Default Configuration
|
||||
|
||||
```typescript
|
||||
// In QueryClientProvider setup
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes (was cacheTime)
|
||||
refetchOnWindowFocus: false, // Don't refetch on focus
|
||||
refetchOnMount: false, // Don't refetch on mount if fresh
|
||||
retry: 1, // Retry failed queries once
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Per-Query Overrides
|
||||
|
||||
```typescript
|
||||
// Frequently changing data - shorter staleTime
|
||||
useSuspenseQuery({
|
||||
queryKey: ['notifications', 'unread'],
|
||||
queryFn: () => notificationApi.getUnread(),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
|
||||
// Rarely changing data - longer staleTime
|
||||
useSuspenseQuery({
|
||||
queryKey: ['form', blogId, 'structure'],
|
||||
queryFn: () => formApi.getStructure(blogId),
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Modern Data Fetching Recipe:**
|
||||
|
||||
1. **Create API Service**: `features/X/api/XApi.ts` using apiClient
|
||||
2. **Use useSuspenseQuery**: In components wrapped by SuspenseLoader
|
||||
3. **Cache-First**: Check grid cache before API call
|
||||
4. **Query Keys**: Consistent naming ['entity', id]
|
||||
5. **Route Format**: `/blog/route` NOT `/api/blog/route`
|
||||
6. **Mutations**: invalidateQueries after success
|
||||
7. **Error Handling**: onError + useMuiSnackbar
|
||||
8. **Type Safety**: Type all parameters and returns
|
||||
|
||||
**See Also:**
|
||||
- [component-patterns.md](component-patterns.md) - Suspense integration
|
||||
- [loading-and-error-states.md](loading-and-error-states.md) - SuspenseLoader usage
|
||||
- [complete-examples.md](complete-examples.md) - Full working examples
|
||||
Reference in New Issue
Block a user