| name | tanstack-query |
| description | TanStack Query (React Query) patterns for data fetching, caching, mutations, and optimistic updates. Use when implementing data fetching, managing server state, handling API calls, or working with React Query. |
TanStack Query Patterns
Patterns for efficient data fetching and server state management with TanStack Query v5.
Setup
Provider setup
'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,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Basic Queries
Simple query
'use client';
import { useQuery } from '@tanstack/react-query';
export function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{data.name}</div>;
}
Query with enabled option
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId,
});
Query with dependent data
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user!.id),
enabled: !!user?.id,
});
Query Keys
Key structure best practices
queryKey: ['todos']
queryKey: ['todo', todoId]
queryKey: ['todos', { status: 'done', page: 1 }]
queryKey: ['users', userId, 'posts', postId, 'comments']
Query key factory pattern
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,
};
useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
});
queryClient.invalidateQueries({ queryKey: userKeys.all });
queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
Mutations
Basic mutation
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function CreateTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newTodo: { title: string }) => {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
if (!res.ok) throw new Error('Failed to create todo');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const title = new FormData(form).get('title') as string;
mutation.mutate({ title });
}}
>
<input name="title" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create'}
</button>
{mutation.error && <p>Error: {mutation.error.message}</p>}
</form>
);
}
Mutation with optimistic updates
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
const previousTodo = queryClient.getQueryData(['todos', newTodo.id]);
queryClient.setQueryData(['todos', newTodo.id], newTodo);
return { previousTodo };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(
['todos', newTodo.id],
context?.previousTodo
);
},
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({ queryKey: ['todos', variables.id] });
},
});
Optimistic update for lists
const mutation = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old: Todo[]) => [
...old,
{ ...newTodo, id: 'temp-id', status: 'pending' },
]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Infinite Queries
import { useInfiniteQuery } from '@tanstack/react-query';
export function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }) => {
const res = await fetch(`/api/posts?cursor=${pageParam}`);
return res.json();
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading...'
: hasNextPage
? 'Load More'
: 'No more posts'}
</button>
</div>
);
}
Prefetching
Prefetch on hover
export function PostLink({ postId }: { postId: string }) {
const queryClient = useQueryClient();
const prefetchPost = () => {
queryClient.prefetchQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
staleTime: 60 * 1000,
});
};
return (
<Link
href={`/posts/${postId}`}
onMouseEnter={prefetchPost}
>
View Post
</Link>
);
}
Prefetch in Server Component
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function PostsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsList />
</HydrationBoundary>
);
}
Custom Hooks Pattern
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from '@/lib/queries/keys';
export function useUser(userId: string) {
return useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
enabled: !!userId,
});
}
export function useUsers(filters?: UserFilters) {
return useQuery({
queryKey: userKeys.list(filters ?? {}),
queryFn: () => fetchUsers(filters),
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUser,
onSuccess: (data) => {
queryClient.setQueryData(userKeys.detail(data.id), data);
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
const { data: user, isLoading } = useUser(userId);
const updateUser = useUpdateUser();
updateUser.mutate({ id: userId, name: 'New Name' });
Error Handling
Global error handler
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof Error && error.message.includes('404')) {
return false;
}
return failureCount < 3;
},
},
mutations: {
onError: (error) => {
toast({
variant: 'destructive',
title: 'Error',
description: error.message,
});
},
},
},
});
Per-query error handling
const { data, error, isError } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
retry: false,
throwOnError: false,
});
if (isError) {
return <ErrorMessage error={error} />;
}