Налаштування React Query (TanStack Query) для управління серверним станом
React Query розділяє два принципово різних типи стану: клієнтський (UI, форми) та серверний (дані з API). Серверний стан — асинхронний, кешується, застаріває. React Query керує кешем, фоновими оновленнями, дедублюванням запитів, пагінацією та інвалідацією.
Результат: позбавляються ~60–70% коду для роботи з даними — loading, error, useEffect + fetch замінюються одним хуком.
Що входить у роботу
Встановлення та конфігурація QueryClient, написання користувацьких хуків для всіх кінцевих точок, мутації з оптимістичним оновленням, інвалідація, 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>
)
}
Ключі запитів — конвенція
Ключ запиту — унікальний ідентифікатор запиту. Кеш та інвалідація залежать від нього:
// 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, // не миготить при переході сторінок
})
}
Нескінченна прокрутка
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 при наведенні на посилання
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 з розумними параметрами за замовчуванням, проектуємо ієрархію ключів запитів, пишемо користувацькі хуки для всіх API-кінцевих точок, реалізуємо мутації з оптимістичним оновленням для критичних форм, налаштовуємо prefetching та інвалідацію, при необхідності інтегруємо з SSR.
Строк: 2–5 днів залежно від кількості кінцевих точок та наявності SSR.







