| name | tanstack-query |
| description | TanStack Query (React Query v5) implementation guide. Load whenever fetching, caching, or mutating server data in a React application. Covers setup, query key strategy, useQuery, useMutation, optimistic updates, pagination, and prefetching. Use whenever you see @tanstack/react-query imports or when implementing data fetching that needs caching, loading states, or invalidation. |
TanStack Query (React Query v5) for TypeScript SaaS
TanStack Query is the industry standard for server state management in React. It eliminates the need for useState + useEffect data fetching by handling caching, synchronization, background refetching, and invalidation automatically.
1. Setup & Configuration
Installation
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools
QueryClient Configuration
Create a centralized queryClient instance with sensible defaults for SaaS:
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
retry: 1,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
mutations: {
retry: 0,
},
},
});
Key settings explained:
staleTime: Data is considered fresh. During this period, queries won't refetch even if they're remounted.
gcTime: How long inactive queries are cached in memory. After this, they're garbage collected.
retry: Automatic retry for network failures. Set to 0 for mutations to avoid duplicate operations.
retryDelay: Exponential backoff prevents hammering your server on network issues.
Provider Setup
Wrap your app with QueryClientProvider and optionally include DevTools:
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from '@/lib/queryClient';
export function App() {
return (
<QueryClientProvider client={queryClient}>
{/* Your app routes/components */}
<RouterProvider router={router} />
{/* Only included in development */}
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
}
ReactQueryDevtools is a browser DevTools panel showing all active queries, their state, cache entries, and mutation history. Essential for debugging.
2. Query Key Strategy (Critical Architecture)
This is the most important architectural decision. Query keys are string/number arrays used to identify, cache, and invalidate queries. Bad key design leads to cache misses, stale data, and hard-to-debug state.
The Factory Pattern
Create query key factories near your API functions:
import { UserFilters } from './types';
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 subscriptionKeys = {
all: ['subscriptions'] as const,
lists: () => [...subscriptionKeys.all, 'list'] as const,
list: (teamId: string) => [...subscriptionKeys.lists(), teamId] as const,
detail: (id: string) => [...subscriptionKeys.all, 'detail', id] as const,
};
Why This Pattern?
- Granular Invalidation: Invalidate
userKeys.list({ search: 'john' }) without affecting userKeys.detail('user-123').
- No Typos: Use factories instead of magic strings (
invalidateQueries({ queryKey: userKeys.list(filters) })).
- Easy Refactoring: If you change filter structure, update the factory once; all usages follow.
- Hierarchical: Parent keys (e.g.,
userKeys.all) can invalidate all child queries.
Using Keys in Components
const { data: user } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => api.users.getById(userId),
});
const { mutate: createUser } = useMutation({
mutationFn: (data: CreateUserInput) => api.users.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
3. useQuery Hook
useQuery is for fetching read-only server data. It handles loading, error, and caching automatically.
Basic Usage
import { useQuery } from '@tanstack/react-query';
import { userKeys } from '@/api/users/queries';
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, isError, error } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => api.users.getById(userId),
});
if (isLoading) return <Skeleton height={200} />;
if (isError) return <Alert color="red">Failed: {error.message}</Alert>;
return <div>{user?.name}</div>;
}
Advanced Options
const { data, isLoading, isPending, isFetching, isError } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
},
staleTime: 1000 * 60 * 5,
enabled: !!userId,
select: (user) => ({ ...user, displayName: user.firstName + ' ' + user.lastName }),
placeholderData: { id: '', name: 'Loading...' },
retry: (failureCount, error: any) => {
if (error.status === 401 || error.status === 403) return false;
return failureCount < 2;
},
});
Key differences:
isLoading: Query has no cached data and is fetching (initial load).
isPending: Alias for isLoading (same behavior).
isFetching: Any fetch is in progress, including background refetches. Useful for showing "updating..." spinners.
isError: The last fetch failed.
The select Transform
Transform data without refetching:
const { data: displayName } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => api.users.getById(userId),
select: (user) => `${user.firstName} ${user.lastName}`,
});
This is cheaper than fetching, is memoized by React Query, and updates when dependencies change.
Dependent Queries
Fetch data conditionally using the enabled flag:
function TeamMembersPage({ teamId }: { teamId: string | null }) {
const { data: team } = useQuery({
queryKey: userKeys.detail(teamId!),
queryFn: () => api.teams.getById(teamId!),
enabled: !!teamId,
});
const { data: members } = useQuery({
queryKey: teamMemberKeys.list(teamId!),
queryFn: () => api.teams.getMembers(teamId!),
enabled: !!teamId && !!team,
});
}
4. useMutation Hook
useMutation is for mutations (POST, PUT, DELETE, PATCH). Unlike queries, mutations don't cache and require explicit action to execute.
Basic Usage
import { useMutation } from '@tanstack/react-query';
function CreateTeamForm() {
const { mutate, isPending, error } = useMutation({
mutationFn: (data: CreateTeamInput) => api.teams.create(data),
});
const handleSubmit = (formData: CreateTeamInput) => {
mutate(formData);
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(/* ... */); }}>
<Button loading={isPending} type="submit">Create Team</Button>
{error && <Alert color="red">{error.message}</Alert>}
</form>
);
}
With onSuccess & Query Invalidation
const { mutate } = useMutation({
mutationFn: (data: CreateTeamInput) => api.teams.create(data),
onSuccess: (newTeam) => {
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
queryClient.setQueryData(teamKeys.detail(newTeam.id), newTeam);
},
onError: (error) => {
notifications.show({
color: 'red',
message: error.message,
});
},
onSettled: () => {
form.reset();
},
});
Handling Mutation Variables
type UpdateUserInput = { id: string; name: string };
const { mutate } = useMutation<void, Error, UpdateUserInput>({
mutationFn: async ({ id, name }) => {
await api.users.update(id, { name });
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) });
},
});
mutate({ id: 'user-123', name: 'John' });
Reset Mutation State
const mutation = useMutation();
mutation.reset();
5. Optimistic Updates
When user confidence is high (e.g., toggling a like, updating a subscription), update the UI immediately while the request is in flight. If it fails, rollback.
interface Post {
id: string;
title: string;
likes: number;
liked: boolean;
}
function LikeButton({ post }: { post: Post }) {
const { mutate } = useMutation({
mutationFn: async (liked: boolean) => {
await api.posts.updateLike(post.id, { liked });
},
onMutate: async (liked) => {
await queryClient.cancelQueries({ queryKey: postKeys.detail(post.id) });
const previousData = queryClient.getQueryData<Post>(postKeys.detail(post.id));
queryClient.setQueryData(postKeys.detail(post.id), (old: Post) => ({
...old,
liked,
likes: old.likes + (liked ? 1 : -1),
}));
return { previousData };
},
onError: (err, _, context) => {
if (context?.previousData) {
queryClient.setQueryData(postKeys.detail(post.id), context.previousData);
}
notifications.show({ color: 'red', message: 'Failed to update like' });
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: postKeys.detail(post.id) });
},
});
return (
<Button
variant={post.liked ? 'filled' : 'outline'}
onClick={() => mutate(!post.liked)}
>
{post.likes} Likes
</Button>
);
}
Key points:
onMutate runs before the mutation, updates the cache, and can return a context object for onError.
cancelQueries prevents stale refetches from overwriting optimistic updates.
- Always provide a rollback path in
onError.
onSettled is your last chance to sync with the server.
6. Pagination
Offset Pagination with keepPreviousData
Fetch pages without losing the previous page's data (smooth transitions):
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { userKeys } from '@/hooks/useUsers';
import { listUsers } from '@/services/userService';
function UsersList() {
const [page, setPage] = useState(1);
const { data, isPending } = useQuery({
queryKey: userKeys.list({ page, limit: 20 }),
queryFn: () => listUsers({ page, limit: 20 }),
placeholderData: (previousData) => previousData,
});
return (
<>
<UserTable users={data?.users} isLoading={isPending} />
<Pager
page={page}
totalPages={data?.totalPages ?? 1}
onChange={setPage}
disabled={isPending}
/>
</>
);
}
<Pager> is a small component built on shadcn primitives — use the Pagination components from shadcn (pnpm dlx shadcn@latest add pagination) rather than pulling in a third-party pager.
Cursor-Based Pagination with useInfiniteQuery
For "Load More" buttons or infinite scroll:
import { useInfiniteQuery } from '@tanstack/react-query';
interface UsersResponse {
users: User[];
nextCursor: string | null;
}
function InfiniteUsersList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery<UsersResponse>({
queryKey: userKeys.infinite(),
queryFn: ({ pageParam = null }) =>
api.users.listInfinite({ cursor: pageParam, limit: 20 }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: null,
});
const allUsers = data?.pages.flatMap((page) => page.users) ?? [];
return (
<>
{allUsers.map((user) => (
<UserCard key={user.id} user={user} />
))}
<Button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
loading={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'Done'}
</Button>
</>
);
}
Infinite Scroll with Intersection Observer
import { useRef, useEffect } from 'react';
function InfiniteScrollUsers() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery();
const observerTarget = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!observerTarget.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
observer.observe(observerTarget.current);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<>
{data?.pages.flatMap((page) => page.users).map((user) => (
<UserCard key={user.id} user={user} />
))}
<div ref={observerTarget} style={{ height: '100px' }}>
{isFetchingNextPage && <Spinner />}
</div>
</>
);
}
7. Prefetching & Seeding
Prefetch on Hover/Navigation Intent
Prefetch before the user navigates:
import { useMutation } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
function UserLink({ userId, children }: { userId: string; children: React.ReactNode }) {
const queryClient = useQueryClient();
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: userKeys.detail(userId),
queryFn: () => api.users.getById(userId),
staleTime: 1000 * 60,
});
};
return (
<Link to={`/users/${userId}`} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}
Seed Cache from Server-Rendered Data
If you're using Next.js or SSR, seed the cache with initial data to avoid refetching:
export async function getServerSideProps({ params }: { params: { userId: string } }) {
const dehydratedState = dehydrate(queryClient);
const user = await api.users.getById(params.userId);
queryClient.setQueryData(userKeys.detail(params.userId), user);
return {
props: {
dehydratedState,
userId: params.userId,
},
};
}
function UserPage({ dehydratedState, userId }: PageProps) {
return (
<HydrationBoundary state={dehydratedState}>
<UserProfile userId={userId} />
</HydrationBoundary>
);
}
8. shadcn/ui Integration
Loading States on Buttons
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { useMutation } from '@tanstack/react-query'
import { createSubscription } from '@/services/subscriptionService'
import { queryClient } from '@/lib/queryClient'
import { subscriptionKeys } from '@/hooks/useSubscriptions'
function SubscribeButton({ teamId }: { teamId: string }) {
const { mutate, isPending } = useMutation({
mutationFn: () => createSubscription(teamId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.list(teamId) })
},
})
return (
<Button onClick={() => mutate()} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Subscribe
</Button>
)
}
Skeleton Loading States
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent } from '@/components/ui/card'
import { useQuery } from '@tanstack/react-query'
import { fetchUser } from '@/services/userService'
import { userKeys } from '@/hooks/useUser'
function UserProfileCard({ userId }: { userId: string }) {
const { data, isLoading } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
})
if (isLoading) {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-5 w-[70%]" />
<Skeleton className="h-5 w-[50%]" />
</div>
)
}
return (
<Card>
<CardContent className="flex flex-col gap-2 pt-6">
<h2 className="text-lg font-semibold">{data?.name}</h2>
<p className="text-muted-foreground">{data?.email}</p>
<p className="text-muted-foreground">{data?.role}</p>
</CardContent>
</Card>
)
}
Notifications on Success/Error
Use sonner (shipped by shadcn's toast add):
import { toast } from 'sonner'
import { useMutation } from '@tanstack/react-query'
import { inviteTeamMember } from '@/services/teamService'
import { queryClient } from '@/lib/queryClient'
import { teamMemberKeys } from '@/hooks/useTeamMembers'
function InviteUserForm() {
const { mutate } = useMutation({
mutationFn: (email: string) => inviteTeamMember(email),
onSuccess: () => {
toast.success('User invited successfully')
queryClient.invalidateQueries({ queryKey: teamMemberKeys.lists() })
},
onError: (error) => {
toast.error(error.message)
},
})
return (
<form onSubmit={(e) => { e.preventDefault(); mutate(/* ... */) }}>
{/* form fields */}
</form>
)
}
9. TypeScript Patterns
Typed Query Functions
import { QueryFunctionContext } from '@tanstack/react-query';
type UserParams = { id: string };
const getUserDetail = async (context: QueryFunctionContext<ReturnType<typeof userKeys.detail>>) => {
const [, , userId] = context.queryKey;
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch');
return res.json() as Promise<User>;
};
const { data } = useQuery({
queryKey: userKeys.detail('user-123'),
queryFn: getUserDetail,
});
Inferring Return Types
const queryFn = async () => {
const res = await fetch('/api/users');
return res.json() as Promise<User[]>;
};
type UserData = Awaited<ReturnType<typeof queryFn>>;
const { data }: { data?: UserData } = useQuery({
queryKey: userKeys.lists(),
queryFn,
});
QueryFunctionContext
Automatically passes query keys to your function:
interface ListParams { page: number; search: string }
const listUsers = async ({ queryKey }: QueryFunctionContext<[string, string, ListParams]>) => {
const [, , { page, search }] = queryKey;
return api.users.list({ page, search });
};
const { data } = useQuery({
queryKey: userKeys.list({ page: 1, search: 'john' }),
queryFn: listUsers,
});
10. Common Mistakes
❌ Storing Server State in useState
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
api.users.list().then(setUsers).finally(() => setLoading(false));
}, []);
const { data: users } = useQuery({
queryKey: userKeys.lists(),
queryFn: () => api.users.list(),
});
❌ Stale Closures in onSuccess
const { mutate } = useMutation({
mutationFn: () => api.users.update(userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
},
});
const { mutate } = useMutation({
mutationFn: ({ userId, data }: UpdateUserInput) => api.users.update(userId, data),
onSuccess: (_, { userId }) => {
queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
},
});
❌ Over-Invalidating
queryClient.invalidateQueries();
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
❌ Missing enabled Flag on Dependent Queries
const { data } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => api.users.getById(userId),
});
const { data } = useQuery({
queryKey: userKeys.detail(userId!),
queryFn: () => api.users.getById(userId!),
enabled: !!userId,
});
❌ Forgetting to Pass Context in Optimistic Updates
const { mutate } = useMutation({
mutationFn: (data) => api.update(data),
onMutate: (data) => {
const previous = queryClient.getQueryData();
queryClient.setQueryData(, optimistic);
},
onError: (err, _, context) => {
},
});
const { mutate } = useMutation({
mutationFn: (data) => api.update(data),
onMutate: (data) => {
const previous = queryClient.getQueryData();
queryClient.setQueryData(, optimistic);
return { previous };
},
onError: (err, _, context) => {
queryClient.setQueryData(, context?.previous);
},
});
Summary
TanStack Query transforms how you build React applications:
- Setup once: Configure QueryClient, wrap app with provider.
- Design keys: Use factory pattern for granular control and refactoring.
- useQuery: Fetch and cache read-only data.
- useMutation: Handle mutations with invalidation and error handling.
- Optimize: Prefetch, seed cache, use infinite queries for large datasets.
- Integrate: shadcn/ui provides visual primitives; TanStack Query owns server state.
For advanced patterns (SSR, suspense, global error handling), see references/patterns.md.