react-query-patterns
SKILL.md
TanStack React Query Patterns
Setup
Query Client
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Queries
Basic Query
Fetch data with automatic caching:
'use client';
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
export function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json() as Promise<User>;
},
});
if (isLoading) return <LoadingSkeleton />;
if (error) return <ErrorMessage error={error} />;
return <div>{data.name}</div>;
}
Query with Options
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['posts', { page, filter }],
queryFn: () => fetchPosts(page, filter),
enabled: !!userId, // Only run if userId exists
staleTime: 5 * 60 * 1000, // Data fresh for 5 minutes
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
retry: 3, // Retry failed requests 3 times
refetchInterval: 30 * 1000, // Refetch every 30 seconds
refetchOnWindowFocus: true, // Refetch when window regains focus
});
Query Keys
Structure
Hierarchical query key organization:
| Pattern | Example |
|---|---|
| Simple | ['users'] |
| With ID | ['users', userId] |
| With params | ['posts', { page, filter }] |
| Nested | ['users', userId, 'posts'] |
Best Practices
- Use arrays for all query keys
- Order from general to specific
- Include all variables that affect the query
- Use objects for multiple parameters
- Keep keys consistent across the app
Query Key Factory
// Query key organization
const queryKeys = {
users: ['users'] as const,
user: (id: string) => ['users', id] as const,
userPosts: (id: string) => ['users', id, 'posts'] as const,
posts: {
all: ['posts'] as const,
lists: () => ['posts', 'list'] as const,
list: (filters: PostFilters) => ['posts', 'list', filters] as const,
detail: (id: string) => ['posts', id] as const,
},
};
// Usage
const { data } = useQuery({
queryKey: queryKeys.user(userId),
queryFn: () => fetchUser(userId),
});
Mutations
Basic Mutation
Modify server data:
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function CreatePostForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newPost: NewPost) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
if (!response.ok) throw new Error('Failed to create post');
return response.json();
},
onSuccess: () => {
// Invalidate and refetch posts query
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const handleSubmit = (data: NewPost) => {
mutation.mutate(data);
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
{mutation.isPending && <p>Creating post...</p>}
{mutation.isError && <p>Error: {mutation.error.message}</p>}
{mutation.isSuccess && <p>Post created!</p>}
</form>
);
}
Optimistic Updates
Update UI immediately before server responds:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot current value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old: Todo[]) => {
return old.map((todo) =>
todo.id === newTodo.id ? newTodo : todo
);
});
// Return context with snapshot
return { previousTodos };
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Mutation with Invalidation
const mutation = useMutation({
mutationFn: deletePost,
onSuccess: (_, deletedPostId) => {
// Invalidate list query
queryClient.invalidateQueries({ queryKey: ['posts', 'list'] });
// Remove deleted post from cache
queryClient.removeQueries({ queryKey: ['posts', deletedPostId] });
// Or update cache manually
queryClient.setQueryData(['posts', 'list'], (old: Post[]) => {
return old.filter((post) => post.id !== deletedPostId);
});
},
});
Infinite Queries
Load more data as user scrolls:
import { useInfiniteQuery } from '@tanstack/react-query';
export function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: async ({ pageParam }) => {
const response = await fetch(`/api/posts?page=${pageParam}`);
return response.json();
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length + 1 : undefined;
},
});
if (isLoading) return <LoadingSkeleton />;
return (
<div>
{data.pages.map((page, i) => (
<div key={i}>
{page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Prefetching
Hover Prefetch
Prefetch data on hover for instant navigation:
import { useQueryClient } from '@tanstack/react-query';
export function PostLink({ postId }: { postId: string }) {
const queryClient = useQueryClient();
const prefetchPost = () => {
queryClient.prefetchQuery({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
staleTime: 60 * 1000, // Keep for 1 minute
});
};
return (
<Link
href={`/posts/${postId}`}
onMouseEnter={prefetchPost}
onTouchStart={prefetchPost}
>
View Post
</Link>
);
}
Page Prefetch
const { data } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
});
// Prefetch next page
useEffect(() => {
if (data?.hasMore) {
queryClient.prefetchQuery({
queryKey: ['posts', page + 1],
queryFn: () => fetchPosts(page + 1),
});
}
}, [data, page, queryClient]);
Dependent Queries
Query depends on result of previous query:
// 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: () => fetchUserProjects(user.id),
enabled: !!user, // Only run when user exists
});
Parallel Queries
Multiple Independent Queries
export 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 <LoadingSkeleton />;
}
return (
<div>
<UserList users={users.data} />
<PostList posts={posts.data} />
<Stats data={stats.data} />
</div>
);
}
useQueries for Dynamic Queries
import { useQueries } from '@tanstack/react-query';
export function MultiUserView({ userIds }: { userIds: string[] }) {
const userQueries = useQueries({
queries: userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})),
});
const isLoading = userQueries.some((query) => query.isLoading);
const users = userQueries.map((query) => query.data);
if (isLoading) return <LoadingSkeleton />;
return (
<div>
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
Error Handling
Retry Logic
const { data, error } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
retry: (failureCount, error) => {
// Don't retry on 404
if (error.status === 404) return false;
// Retry up to 3 times
return failureCount < 3;
},
retryDelay: (attemptIndex) => {
// Exponential backoff: 1s, 2s, 4s
return Math.min(1000 * 2 ** attemptIndex, 30000);
},
});
Error Boundaries
import { useQuery } from '@tanstack/react-query';
import { useErrorBoundary } from 'react-error-boundary';
export function CriticalData() {
const { showBoundary } = useErrorBoundary();
const { data } = useQuery({
queryKey: ['critical'],
queryFn: fetchCriticalData,
throwOnError: true, // Throw errors to Error Boundary
});
return <div>{/* render data */}</div>;
}
Best Practices
Do
- Use query keys as array of dependencies
- Invalidate queries after mutations
- Prefetch on hover for better UX
- Use staleTime to reduce unnecessary refetches
- Implement optimistic updates for instant feedback
- Use enabled option for dependent queries
- Keep query functions pure and reusable
Don't
- Don't use query keys as strings (use arrays)
- Don't mutate query data directly
- Don't forget to handle loading and error states
- Don't set staleTime too low (causes excessive requests)
- Don't invalidate all queries (be specific)
- Don't put business logic in queryFn (use services)
Performance
Optimization
- Set appropriate staleTime (avoid unnecessary refetches)
- Use gcTime to control cache memory usage
- Implement pagination or infinite queries for large lists
- Prefetch predictable user navigation
- Use select option to subscribe to only needed data
Monitoring
- Enable React Query Devtools in development
- Monitor network requests in DevTools
- Check query cache size regularly
- Measure query execution time
Weekly Installs
9
Repository
masanao-ohba/cl…anifestsGitHub Stars
2
First Seen
Jan 29, 2026
Installed on
cursor8
opencode7
gemini-cli7
github-copilot7
codex7
claude-code5