19 KiB
19 KiB
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
isLoadingchecks needed - Integrates with Suspense boundaries
- Cleaner component code
- Consistent loading UX
- Better error handling with error boundaries
Basic Pattern
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:
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 freshgcTime: How long unused data stays in cacherefetchOnWindowFocus: false: User preference
Parallel Data Fetching
useSuspenseQueries
When fetching multiple independent resources:
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
// 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
// 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)
/**
* 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
// ✅ 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
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
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
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
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
// 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
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
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:
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
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
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
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
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
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
// 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
// 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:
- Create API Service:
features/X/api/XApi.tsusing apiClient - Use useSuspenseQuery: In components wrapped by SuspenseLoader
- Cache-First: Check grid cache before API call
- Query Keys: Consistent naming ['entity', id]
- Route Format:
/blog/routeNOT/api/blog/route - Mutations: invalidateQueries after success
- Error Handling: onError + useMuiSnackbar
- Type Safety: Type all parameters and returns
See Also:
- component-patterns.md - Suspense integration
- loading-and-error-states.md - SuspenseLoader usage
- complete-examples.md - Full working examples