Loading...
Loading...
Implements TanStack Query (React Query) for server state management with caching, mutations, optimistic updates, and infinite queries. Use when users request "react query", "tanstack query", "data fetching", "cache management", or "server state".
npx skill4agent add patricio0312rev/skills tanstack-query-setupnpm install @tanstack/react-query @tanstack/react-query-devtools// 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>
);
}// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}// hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
}
// Usage
function UsersList() {
const { data: users, isLoading, error } = useUsers();
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}// hooks/useUser.ts
import { useQuery } from '@tanstack/react-query';
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
export function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // Only fetch when userId exists
});
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useUser(userId);
if (isLoading) return <Skeleton />;
return <div>{user?.name}</div>;
}// hooks/useProducts.ts
interface ProductFilters {
category?: string;
minPrice?: number;
maxPrice?: number;
search?: string;
}
async function fetchProducts(filters: ProductFilters): Promise<Product[]> {
const params = new URLSearchParams();
if (filters.category) params.set('category', filters.category);
if (filters.minPrice) params.set('minPrice', String(filters.minPrice));
if (filters.maxPrice) params.set('maxPrice', String(filters.maxPrice));
if (filters.search) params.set('search', filters.search);
const response = await fetch(`/api/products?${params}`);
return response.json();
}
export function useProducts(filters: ProductFilters) {
return useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
placeholderData: (previousData) => previousData, // Keep previous data while fetching
});
}// hooks/useCreateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserDto {
name: string;
email: string;
}
async function createUser(data: CreateUserDto): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return response.json();
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
onSuccess: (newUser) => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (error) => {
console.error('Failed to create user:', error);
},
});
}
// Usage
function CreateUserForm() {
const { mutate, isPending, isError, error } = useCreateUser();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create User'}
</button>
{isError && <p className="text-red-500">{error.message}</p>}
</form>
);
}// hooks/useUpdateUser.ts
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<User> }) => {
const response = await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return response.json();
},
onSuccess: (updatedUser) => {
// Update the single user cache
queryClient.setQueryData(['users', updatedUser.id], updatedUser);
// Invalidate the list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// hooks/useDeleteUser.ts
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userId: string) => {
await fetch(`/api/users/${userId}`, { method: 'DELETE' });
return userId;
},
onSuccess: (deletedId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: ['users', deletedId] });
// Invalidate list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}// hooks/useToggleTodo.ts
export function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, completed }: { id: string; completed: boolean }) => {
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed }),
});
return response.json();
},
// Optimistic update
onMutate: async ({ id, completed }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistically update
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === id ? { ...todo, completed } : todo
)
);
// Return context for rollback
return { previousTodos };
},
// Rollback on error
onError: (err, variables, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
// Refetch after success or error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}export function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (text: string) => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text }),
});
return response.json();
},
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Add optimistic todo with temp id
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`,
text,
completed: false,
};
queryClient.setQueryData<Todo[]>(['todos'], (old) => [
...(old || []),
optimisticTodo,
]);
return { previousTodos, optimisticTodo };
},
onError: (err, text, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSuccess: (newTodo, text, context) => {
// Replace optimistic todo with real one
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === context?.optimisticTodo.id ? newTodo : todo
)
);
},
});
}// hooks/useInfinitePosts.ts
import { useInfiniteQuery } from '@tanstack/react-query';
interface PostsPage {
posts: Post[];
nextCursor?: string;
}
async function fetchPosts({ pageParam }: { pageParam?: string }): Promise<PostsPage> {
const url = pageParam
? `/api/posts?cursor=${pageParam}`
: '/api/posts';
const response = await fetch(url);
return response.json();
}
export function useInfinitePosts() {
return useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
// Usage with intersection observer
function PostsFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfinitePosts();
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
return (
<div>
{data?.pages.map((page, pageIndex) => (
<Fragment key={pageIndex}>
{page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</Fragment>
))}
<div ref={loadMoreRef} className="h-10">
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}export function useInfiniteProducts() {
return useInfiniteQuery({
queryKey: ['products'],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(`/api/products?offset=${pageParam}&limit=20`);
return response.json();
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
// Stop if less than limit returned
if (lastPage.products.length < 20) return undefined;
return allPages.length * 20;
},
});
}// lib/queries/users.ts
import { queryOptions } from '@tanstack/react-query';
export const userQueries = {
all: () => queryOptions({
queryKey: ['users'],
queryFn: fetchUsers,
}),
detail: (id: string) => queryOptions({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
}),
list: (filters: UserFilters) => queryOptions({
queryKey: ['users', 'list', filters],
queryFn: () => fetchUsers(filters),
}),
posts: (userId: string) => queryOptions({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
}),
};
// Usage
function UserProfile({ userId }: { userId: string }) {
const userQuery = useQuery(userQueries.detail(userId));
const postsQuery = useQuery(userQueries.posts(userId));
// ...
}
// Invalidation
queryClient.invalidateQueries({ queryKey: ['users'] }); // All user queries
queryClient.invalidateQueries({ queryKey: ['users', userId] }); // Specific userfunction UserLink({ userId, children }: { userId: string; children: React.ReactNode }) {
const queryClient = useQueryClient();
const prefetch = () => {
queryClient.prefetchQuery(userQueries.detail(userId));
};
return (
<Link
href={`/users/${userId}`}
onMouseEnter={prefetch}
onFocus={prefetch}
>
{children}
</Link>
);
}// app/users/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { userQueries } from '@/lib/queries/users';
import { UsersList } from './UsersList';
export default async function UsersPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery(userQueries.all());
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UsersList />
</HydrationBoundary>
);
}function UserPosts({ userId }: { userId: string }) {
// First query
const userQuery = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
});
// Dependent query - only runs when user is loaded
const postsQuery = useQuery({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
enabled: !!userQuery.data, // Wait for user
});
if (userQuery.isLoading) return <Spinner />;
return (
<div>
<h2>{userQuery.data?.name}'s Posts</h2>
{postsQuery.isLoading ? (
<Spinner />
) : (
<PostsList posts={postsQuery.data} />
)}
</div>
);
}import { useQueries } from '@tanstack/react-query';
function Dashboard({ userIds }: { userIds: string[] }) {
const userQueries = useQueries({
queries: userIds.map((id) => ({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
})),
});
const isLoading = userQueries.some((q) => q.isLoading);
const users = userQueries.map((q) => q.data).filter(Boolean);
if (isLoading) return <Spinner />;
return (
<div>
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}