Setting up React Query (TanStack Query) for Server State Management
React Query separates two fundamentally different types of state: client (UI, forms) and server (data from API). Server state is asynchronous, cacheable, and stale. React Query handles caching, background updates, request deduplication, pagination, and invalidation.
Result: eliminates ~60–70% of data management code — loading, error, useEffect + fetch are replaced by a single hook.
What's Included
Setting up QueryClient, writing custom hooks for all endpoints, mutations with optimistic updates, invalidation, prefetching, pagination/infinite scroll, server-side rendering integration (SSR/Next.js), DevTools.
Installation
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // data is fresh for 5 minutes
gcTime: 10 * 60 * 1000, // cache is stored for 10 minutes (formerly cacheTime)
retry: 2,
refetchOnWindowFocus: true,
},
mutations: {
retry: 0,
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
Query Keys — Convention
Query key is a unique identifier for the request. Cache and invalidation depend on it:
// queryKeys.ts
export const queryKeys = {
products: {
all: ['products'] as const,
lists: () => [...queryKeys.products.all, 'list'] as const,
list: (filters: ProductFilters) => [...queryKeys.products.lists(), filters] as const,
details: () => [...queryKeys.products.all, 'detail'] as const,
detail: (id: string) => [...queryKeys.products.details(), id] as const,
},
users: {
all: ['users'] as const,
me: () => [...queryKeys.users.all, 'me'] as const,
profile: (id: string) => [...queryKeys.users.all, 'profile', id] as const,
},
}
Basic Hooks
// hooks/useProducts.ts
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { queryKeys } from '@/lib/queryKeys'
export function useProducts(filters: ProductFilters) {
return useQuery({
queryKey: queryKeys.products.list(filters),
queryFn: () => api.get<Product[]>('/products', { params: filters }),
placeholderData: (prev) => prev, // previous data when filters change
})
}
export function useProduct(id: string) {
return useQuery({
queryKey: queryKeys.products.detail(id),
queryFn: () => api.get<Product>(`/products/${id}`),
enabled: !!id, // don't request if id is empty
})
}
// useSuspenseQuery — throws promise (for React Suspense)
export function useProductSuspense(id: string) {
return useSuspenseQuery({
queryKey: queryKeys.products.detail(id),
queryFn: () => api.get<Product>(`/products/${id}`),
})
}
Mutations
// hooks/useProductMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
export function useCreateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateProductDto) => api.post<Product>('/products', data),
onSuccess: (newProduct) => {
// invalidate lists
queryClient.invalidateQueries({ queryKey: queryKeys.products.lists() })
// immediately put details in cache
queryClient.setQueryData(queryKeys.products.detail(newProduct.id), newProduct)
},
onError: (error) => {
toast.error(`Creation error: ${error.message}`)
},
})
}
export function useUpdateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateProductDto }) =>
api.patch<Product>(`/products/${id}`, data),
// optimistic update
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.products.detail(id) })
const previous = queryClient.getQueryData<Product>(queryKeys.products.detail(id))
queryClient.setQueryData(queryKeys.products.detail(id), (old: Product) => ({
...old,
...data,
}))
return { previous }
},
onError: (_, { id }, context) => {
// rollback on error
if (context?.previous) {
queryClient.setQueryData(queryKeys.products.detail(id), context.previous)
}
},
onSettled: (_, __, { id }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.products.detail(id) })
},
})
}
export function useDeleteProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/products/${id}`),
onSuccess: (_, id) => {
queryClient.removeQueries({ queryKey: queryKeys.products.detail(id) })
queryClient.invalidateQueries({ queryKey: queryKeys.products.lists() })
},
})
}
Usage in Component
function ProductList({ filters }: { filters: ProductFilters }) {
const { data, isLoading, isError, error, isFetching } = useProducts(filters)
const { mutate: createProduct, isPending } = useCreateProduct()
if (isLoading) return <Skeleton count={6} />
if (isError) return <ErrorMessage message={error.message} />
return (
<div>
{isFetching && <div className="loading-bar" />}
<ProductGrid products={data} />
<button
onClick={() => createProduct({ name: 'New Product', price: 0 })}
disabled={isPending}
>
Add
</button>
</div>
)
}
Pagination
export function useProductsPage(page: number, pageSize = 20) {
return useQuery({
queryKey: queryKeys.products.list({ page, pageSize }),
queryFn: () => api.get<PaginatedResponse<Product>>('/products', { params: { page, pageSize } }),
placeholderData: keepPreviousData, // no flickering when navigating pages
})
}
Infinite Scroll
import { useInfiniteQuery } from '@tanstack/react-query'
export function useInfiniteProducts(filters: Omit<ProductFilters, 'page'>) {
return useInfiniteQuery({
queryKey: queryKeys.products.list(filters),
queryFn: ({ pageParam }) =>
api.get<PaginatedResponse<Product>>('/products', {
params: { ...filters, page: pageParam, pageSize: 20 },
}),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.hasNextPage ? lastPage.page + 1 : undefined,
})
}
function InfiniteProductList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteProducts({})
const products = data?.pages.flatMap((p) => p.items) ?? []
return (
<>
<ProductGrid products={products} />
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'More'}
</button>
)}
</>
)
}
Prefetching
// prefetch on hover over link
function ProductLink({ id }: { id: string }) {
const queryClient = useQueryClient()
return (
<Link
to={`/products/${id}`}
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: queryKeys.products.detail(id),
queryFn: () => api.get<Product>(`/products/${id}`),
staleTime: 60_000,
})
}}
>
Go to
</Link>
)
}
SSR with Next.js App Router
// app/products/page.tsx (Next.js 14+)
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query'
import { ProductList } from './ProductList'
export default async function ProductsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: queryKeys.products.lists(),
queryFn: () => fetchProductsServer(),
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductList />
</HydrationBoundary>
)
}
What We Do
Install QueryClient with sensible defaults, design a hierarchy of query keys, write custom hooks for all API endpoints, implement mutations with optimistic updates for critical forms, configure prefetching and invalidation, integrate with SSR if needed.
Timeline: 2–5 days depending on the number of endpoints and SSR requirements.







