TanStack Query setup for data fetching and caching. Use when implementing server state management.
This skill inherits all available tools. When active, it can use any tool Claude has access to.
This skill covers TanStack Query (React Query) for server state management.
Use this skill when:
SERVER STATE IS DIFFERENT - Server state is async, cached, and can become stale. TanStack Query handles this complexity.
npm install @tanstack/react-query
// 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 }): React.ReactElement {
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>
);
}
// lib/queryKeys.ts
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: PostFilters) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (id: string) => [...postKeys.details(), id] as const,
byUser: (userId: string) => [...postKeys.all, 'user', userId] as const,
};
import { useQuery } from '@tanstack/react-query';
import { userKeys } from '@/lib/queryKeys';
interface User {
id: string;
name: string;
email: string;
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
export function useUser(userId: string) {
return useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!userId, // Only fetch if userId exists
});
}
// Usage in component
function UserProfile({ userId }: { userId: string }): React.ReactElement {
const { data: user, isLoading, error } = useUser(userId);
if (isLoading) return <Loading />;
if (error) return <Error message={error.message} />;
if (!user) return <NotFound />;
return <div>{user.name}</div>;
}
interface UserFilters {
page: number;
limit: number;
search?: string;
}
interface UsersResponse {
users: User[];
total: number;
page: number;
}
async function fetchUsers(filters: UserFilters): Promise<UsersResponse> {
const params = new URLSearchParams({
page: String(filters.page),
limit: String(filters.limit),
...(filters.search && { search: filters.search }),
});
const response = await fetch(`/api/users?${params}`);
if (!response.ok) throw new Error('Failed to fetch users');
return response.json();
}
export function useUsers(filters: UserFilters) {
return useQuery({
queryKey: userKeys.list(filters),
queryFn: () => fetchUsers(filters),
placeholderData: (previousData) => previousData, // Keep previous data while fetching
});
}
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserInput {
name: string;
email: string;
}
async function createUser(input: CreateUserInput): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error('Failed to create user');
return response.json();
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
onSuccess: () => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
// Usage
function CreateUserForm(): React.ReactElement {
const createUser = useCreateUser();
const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createUser.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={createUser.isPending}>
{createUser.isPending ? 'Creating...' : 'Create'}
</button>
{createUser.error && <p>{createUser.error.message}</p>}
</form>
);
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: userKeys.detail(newUser.id) });
// Snapshot previous value
const previousUser = queryClient.getQueryData<User>(
userKeys.detail(newUser.id)
);
// Optimistically update
queryClient.setQueryData(userKeys.detail(newUser.id), newUser);
// Return context with snapshot
return { previousUser };
},
onError: (_err, newUser, context) => {
// Rollback on error
if (context?.previousUser) {
queryClient.setQueryData(
userKeys.detail(newUser.id),
context.previousUser
);
}
},
onSettled: (_data, _error, newUser) => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: userKeys.detail(newUser.id) });
},
});
}
import { useInfiniteQuery } from '@tanstack/react-query';
interface PostsPage {
posts: Post[];
nextCursor: string | null;
}
async function fetchPosts(cursor?: string): Promise<PostsPage> {
const params = cursor ? `?cursor=${cursor}` : '';
const response = await fetch(`/api/posts${params}`);
return response.json();
}
export function useInfinitePosts() {
return useInfiniteQuery({
queryKey: postKeys.lists(),
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
}
// Usage
function PostList(): React.ReactElement {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfinitePosts();
return (
<div>
{data?.pages.map((page, i) => (
<React.Fragment key={i}>
{page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</React.Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more'}
</button>
</div>
);
}
import { useQueryClient } from '@tanstack/react-query';
function UserList(): React.ReactElement {
const queryClient = useQueryClient();
const prefetchUser = (userId: string): void => {
queryClient.prefetchQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000,
});
};
return (
<ul>
{users.map((user) => (
<li
key={user.id}
onMouseEnter={() => prefetchUser(user.id)}
>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))}
</ul>
);
}
function useUserPosts(userId: string | undefined) {
const userQuery = useUser(userId ?? '');
return useQuery({
queryKey: postKeys.byUser(userId ?? ''),
queryFn: () => fetchUserPosts(userId!),
enabled: !!userId && userQuery.isSuccess,
});
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// Don't retry on 4xx errors
if (error instanceof Response && error.status >= 400 && error.status < 500) {
return false;
}
return failureCount < 3;
},
},
mutations: {
onError: (error) => {
// Global error handling
console.error('Mutation error:', error);
},
},
},
});
select option for derived data| Use Case | Tool |
|---|---|
| API data | TanStack Query |
| User authentication state | Zustand |
| Shopping cart | Zustand |
| Theme/settings | Zustand |
| Form state | React Hook Form |