Настройка React Query (TanStack Query) для управления серверным состоянием
React Query разделяет два принципиально разных вида состояния: клиентское (UI, формы) и серверное (данные с API). Серверное состояние — асинхронное, кэшируемое, устаревающее. React Query берёт на себя кэш, фоновые обновления, дедупликацию запросов, пагинацию и инвалидацию.
Результат: уходит ~60–70% кода для работы с данными — loading, error, useEffect + fetch заменяются одним хуком.
Что входит в работу
Установка и настройка QueryClient, написание кастомных хуков для всех endpoints, мутации с оптимистичным обновлением, инвалидация, prefetching, пагинация/infinite scroll, интеграция с серверным рендерингом (SSR/Next.js), DevTools.
Установка
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, // данные свежие 5 минут
gcTime: 10 * 60 * 1000, // кэш хранится 10 минут (бывший cacheTime)
retry: 2,
refetchOnWindowFocus: true,
},
mutations: {
retry: 0,
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
Query keys — соглашение
Query key — уникальный идентификатор запроса. От него зависит кэш и инвалидация:
// 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,
},
}
Базовые хуки
// 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, // предыдущие данные при смене фильтров
})
}
export function useProduct(id: string) {
return useQuery({
queryKey: queryKeys.products.detail(id),
queryFn: () => api.get<Product>(`/products/${id}`),
enabled: !!id, // не запрашивать, если id пустой
})
}
// useSuspenseQuery — бросает промис (для React Suspense)
export function useProductSuspense(id: string) {
return useSuspenseQuery({
queryKey: queryKeys.products.detail(id),
queryFn: () => api.get<Product>(`/products/${id}`),
})
}
Мутации
// 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) => {
// инвалидируем списки
queryClient.invalidateQueries({ queryKey: queryKeys.products.lists() })
// сразу кладём в кэш детали
queryClient.setQueryData(queryKeys.products.detail(newProduct.id), newProduct)
},
onError: (error) => {
toast.error(`Ошибка создания: ${error.message}`)
},
})
}
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) })
},
})
}
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() })
},
})
}
Использование в компоненте
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: 'Новый товар', price: 0 })}
disabled={isPending}
>
Добавить
</button>
</div>
)
}
Пагинация
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, // не мигает при переходе страниц
})
}
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 ? 'Загрузка...' : 'Ещё'}
</button>
)}
</>
)
}
Prefetching
// prefetch при hover на ссылку
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,
})
}}
>
Перейти
</Link>
)
}
SSR с 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>
)
}
Что делаем
Устанавливаем QueryClient с разумными defaults, проектируем иерархию query keys, пишем кастомные хуки для всех API-endpoint, реализуем мутации с оптимистичным обновлением для критичных форм, настраиваем prefetching и инвалидацию, при необходимости интегрируем с SSR.
Срок: 2–5 дней в зависимости от количества endpoints и наличия SSR.







