Introduction
React developers constantly write boilerplate for data handling: useEffect, useState, reducers. Every new screen duplicates loading, caching, and updating logic. As a result, 60–70% of the code is boilerplate that distracts from business logic. On one project, we replaced Redux Thunk with TanStack Query, cut the code by 70%, and reduced bugs by a factor of three.
TanStack Query (formerly React Query) is a library for managing server state. It handles caching, background updates, request deduplication, pagination, mutations with optimistic updates, and prefetching. The result: data-related code shrinks by 60–70%, and bugs drop by 2–3 times. We have been configuring TanStack Query since its early days, with over 50 projects completed. We guarantee stable operation and 30–50% time savings. Contact us for a setup and get a complete data architecture in days.
| Approach | Boilerplate | Complexity | Scalability |
|---|---|---|---|
| Redux Thunk | High | High | Medium |
| RTK Query | Medium | Medium | High |
| TanStack Query | Low | Low | High |
| SWR | Low | Low | Medium |
Problems TanStack Query Solves
Manual cache management: without the library, you have to manually synchronize requests, handle loading and errors. TanStack Query automatically caches data, deduplicates parallel requests, and updates the cache on mutations. Another pain point is cache invalidation. With hierarchical keys, you can invalidate all related lists with a single command without touching details.
How to Set Up QueryClient with Optimal Parameters
QueryClient is the central object that manages all queries. Its configuration affects performance. Here are recommended values:
| Parameter | Default | Recommended | Why |
|---|---|---|---|
staleTime |
0 | 5 * 60 * 1000 | Data considered fresh for 5 minutes; no re-fetch on every mount |
gcTime |
5 * 60 * 1000 | 10 * 60 * 1000 | Cache stored 10 minutes after removing references (formerly cacheTime) |
retry |
3 | 2 | Retries on error – enough for transient failures |
refetchOnWindowFocus |
false | true | Automatically update data when returning to the tab |
Example basic initialization:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 2,
refetchOnWindowFocus: true,
},
mutations: {
retry: 0,
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
If the project uses Next.js App Router, we add server-side prefetch via dehydrate and HydrationBoundary.
Why Query Keys Are Key to Efficient Invalidation
Query keys are unique identifiers for a query. The key structure determines how the cache is invalidated after mutations. We use a hierarchical approach:
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,
},
}
This allows invalidating all product lists with one command without touching details.
Main Hooks: useQuery, useMutation, useInfiniteQuery
The basic hook for fetching data is useQuery. It returns loading state, error, and data. For critical UI, use useSuspenseQuery with React Suspense.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
export function useProducts(filters: ProductFilters) {
return useQuery({
queryKey: queryKeys.products.list(filters),
queryFn: () => api.get<Product[]>('/products', { params: filters }),
placeholderData: (prev) => prev,
})
}
export function useUpdateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateProductDto }) =>
api.patch<Product>(`/products/${id}`, data),
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) => {
if (context?.previous) {
queryClient.setQueryData(queryKeys.products.detail(id), context.previous)
}
},
onSettled: (_, __, { id }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.products.detail(id) })
},
})
}
For infinite scroll – useInfiniteQuery:
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),
})
}
For simple pagination, use keepPreviousData in placeholderData.
How to Implement Optimistic Updates Without Pain
Optimistic update: the UI changes before the server responds. TanStack Query makes it easy: in onMutate update the cache, in onError roll back. The example above with useUpdateProduct shows this technique. The user sees instant feedback, and on error the data reverts to its original state.
Prefetching and SSR with Next.js
Prefetching loads data before navigating to a page. For example, on hover over a 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</Link>
)
}
For SSR in Next.js App Router, use server-side prefetchQuery and HydrationBoundary:
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query'
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>
)
}
More details in the official TanStack Query documentation.
What's Included in Our Service
We set up TanStack Query for your project:
- Installation and QueryClient configuration tailored to your API.
- Design of a hierarchical query keys structure for easy invalidation.
- Writing custom hooks for all endpoints: useQuery, useMutation, useInfiniteQuery.
- Implementation of optimistic updates for forms (likes, comments, editing).
- Prefetching setup and integration with SSR/Next.js.
- DevTools connection for debugging.
- Documentation and team training.
Process:
- Analysis – audit of current stack and API, identification of integration points.
- Design – query keys scheme, caching strategy selection.
- Implementation – writing hooks, testing mutations, optimization.
- Testing – validation of invalidation, authorization, edge cases.
- Deployment – rollout, monitoring via DevTools.
Timeline: from 2 to 5 days depending on the number of endpoints. Cost is calculated individually. The savings on future support pay back the investment within weeks.
Contact us for a consultation – we'll help you implement TanStack Query and cut development time by 30–50%. Get a ready-made data architecture in days.







