У сучасній архітектурі вебзастосунків однією з найпоширеніших інженерних помилок довгий час була спроба управляти серверними даними за допомогою інструментів клієнтського стану (Redux, MobX, React Context).
Серверний стан (Server State) кардинально відрізняється від клієнтського (Client State). TanStack Query v5 вирішує завдання синхронізації серверних даних: це не стейт-менеджер у класичному розумінні, а асинхронний розподілений кеш з автоматичною ревалідацією, дедуплікацією мережевих запитів та управлінням життєвим циклом даних.
Спроба зберігати дані бекенду у звичайних клієнтських сховищах призводить до трьох системних дефектів:
useEffect, без дедуплікації браузер відправить п'ять ідентичних HTTP-запитів паралельно.isLoading, isError, таймерів скидання неактивних даних, скасування підвислих запитів при переході зі сторінки та оптимістичних відкатів потребує сотень рядків нестабільного шаблонного коду.Взаємодія компонентів із серверним кешем спирається на такі базові принципи:
useQuery з однаковим ключем, виконується рівно один мережевий запит. Усі компоненти підписуються на єдиний проміс у QueryCache.fresh), вони повертаються миттєво без звернення до мережі. Коли дані застаріли (stale), вони повертаються миттєво, але у фоні запускається тихий запит ревалідації.['orders', 'detail', id] дозволяє виконувати як точкову, так і масову каскадну інвалідацію.// Ієрархія ключів TanStack Query
['orders'] // Усі запити замовлень
['orders', 'list'] // Усі списки замовлень
['orders', 'list', { status }] // Список із фільтром
['orders', 'detail', orderId] // Конкретна карткаСерверний компонент Next.js створює ізольований екземпляр QueryClient без ризику витоку даних між користувачами.
Виконання мережевих запитів паралельно на бекенді. Очікування завершення через await prefetchQuery до відправки HTML.
Серіалізація наповненого серверного кешу в безпечну структуру, що передається в дерево через HydrationBoundary.
Клієнтський useQuery миттєво зчитує готові дані з дегідратованого контексту без показу завантажувальних спінерів.
Після закінчення staleTime TanStack Query прозоро відправляє фонові запити за подіями фокуса вікна, підтримуючи свіжість даних.
Що станеться, якщо в хуку useQuery налаштувати staleTime: 60000 (1 хвилина) і gcTime: 10000 (10 секунд), після чого користувач перейде на іншу сторінку й повернеться назад через 20 секунд?
refetchOnWindowFocus) і синхронізацією між вкладками браузера.prefetchQuery) на сервері всередині RSC з дегідратацією в <HydrationBoundary> для нульового зсуву макета (CLS) і миттєвого First Contentful Paint.AbortSignal.useInfiniteQuery з двоспрямованою пагінацією (getNextPageParam, getPreviousPageParam) та збереженням позиції скролу під час навігації назад.Сирі масиви ключів на зразок ['users', userId, 'posts'], розкидані по компонентах, призводять до одруківок та часткової інвалідації кешу. У production використовується патерн Query Key Factory:
/**
* Суворо типізована ієрархічна фабрика ключів кешу.
* Забезпечує безпечну точкову та каскадну інвалідацію.
*/
export const orderKeys = {
all: ['orders'] as const,
lists: () => [...orderKeys.all, 'list'] as const,
list: (filters: { status?: string; page?: number }) =>
[...orderKeys.lists(), filters] as const,
details: () => [...orderKeys.all, 'detail'] as const,
detail: (id: string) => [...orderKeys.details(), id] as const,
metrics: (dateRange: { from: string; to: string }) =>
[...orderKeys.all, 'metrics', dateRange] as const,
};
// Приклади інвалідації:
// queryClient.invalidateQueries({ queryKey: orderKeys.all }) -> інвалідує абсолютно все щодо замовлень
// queryClient.invalidateQueries({ queryKey: orderKeys.lists() }) -> інвалідує лише списки
// queryClient.invalidateQueries({ queryKey: orderKeys.detail('42') }) -> інвалідує лише замовлення #42В архітектурі Next.js App Router дані запитуються на сервері всередині Server Component за допомогою ізольованого екземпляра QueryClient, серіалізуються через dehydrate та передаються на клієнт через HydrationBoundary:
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { orderKeys } from '@/api/queryKeys';
import { fetchOrderById } from '@/api/orders';
import { OrderDetailsView } from '@/components/OrderDetailsView';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function OrderPage({ params }: PageProps) {
const { id } = await params;
// Створюємо ізольований QueryClient для цього HTTP-запиту (Request-Scoped)
const queryClient = new QueryClient();
// Передзавантаження даних у кеш сервера (Node.js/Edge runtime)
await queryClient.prefetchQuery({
queryKey: orderKeys.detail(id),
queryFn: ({ signal }) => fetchOrderById(id, signal),
});
return (
// Дегідратований стан передається клієнту без водоспаду запитів
<HydrationBoundary state={dehydrate(queryClient)}>
<OrderDetailsView orderId={id} />
</HydrationBoundary>
);
}'use client';
import { useQuery } from '@tanstack/react-query';
import { orderKeys } from '@/api/queryKeys';
import { fetchOrderById } from '@/api/orders';
export function OrderDetailsView({ orderId }: { orderId: string }) {
// Дані віддаються МИТТЄВО з кешу HydrationBoundary без показу спінера завантаження!
const { data: order, status, isFetching } = useQuery({
queryKey: orderKeys.detail(orderId),
queryFn: ({ signal }) => fetchOrderById(orderId, signal),
staleTime: 60 * 1000, // Дані вважаються свіжими 1 хвилину
});
if (status === 'pending') {
return <div className="animate-pulse p-4">Скелетон завантаження...</div>;
}
if (status === 'error') {
return <div className="text-red-400 p-4">Помилка завантаження замовлення</div>;
}
return (
<div className="p-6 border border-border rounded-xl bg-bg-surface">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold font-sans">Замовлення #{order.id}</h1>
{isFetching && (
<span className="text-xs text-accent-cyan animate-pulse">
Синхронізація...
</span>
)}
</div>
<p className="mt-2 text-text-muted">Статус: {order.status}</p>
<p className="text-text-muted">Сума: ${order.totalAmount}</p>
</div>
);
}Патерн Optimistic Update реалізується зв'язкою трьох етапів хука useMutation:
onMutate: Скасування активних запитів, збереження зліпка поточного кешу, синхронний запис оптимістичного значення в кеш через setQueryData.onError: Відновлення збереженого зліпка при мережевому збої.onSettled: Примусова інвалідація для синхронізації з реальною відповіддю бази даних.Виклик queryClient.cancelQueries запобігає перезапису оптимістичного UI відповіддю від раніше запущеного повільного фонового refetch.
Збереження поточного зліпка даних queryClient.getQueryData в повертаний контекст. Синхронне застосування нового значення через setQueryData.
Відправка HTTP-запиту на сервер із передачею AbortSignal. Користувач уже бачить оновлений інтерфейс із нульовою затримкою.
Якщо мережа повернула помилку (500, Offline, 403), onError витягує збережений previousData з контексту та відновлює кеш.
onSettled відпрацьовує завжди (при успіху та при помилці), викликаючи queryClient.invalidateQueries для отримання 100% серверної правди.
Чому в методі onMutate під час виконання оптимістичного оновлення критично важливо викликати await queryClient.cancelQueries({ queryKey }) ПЕРЕД викликом queryClient.setQueryData?
import { useMutation, useQueryClient } from '@tanstack/react-query';
export type TaskStatus = 'TODO' | 'IN_PROGRESS' | 'DONE';
export interface TaskItem {
readonly id: string;
readonly title: string;
readonly status: TaskStatus;
readonly updatedAt: string;
}
export interface UpdateTaskPayload {
readonly taskId: string;
readonly nextStatus: TaskStatus;
}
interface MutationContext {
previousTasks?: TaskItem[];
}
export const taskKeys = {
all: ['tasks'] as const,
lists: () => [...taskKeys.all, 'list'] as const,
};
async function mutateTaskStatusApi(
payload: UpdateTaskPayload,
signal?: AbortSignal
): Promise<TaskItem> {
const response = await fetch(`/api/tasks/${payload.taskId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: payload.nextStatus }),
signal,
});
if (!response.ok) {
throw new Error(`Помилка оновлення статусу завдання: HTTP ${response.status}`);
}
return (await response.json()) as TaskItem;
}
export function useUpdateTaskStatus() {
const queryClient = useQueryClient();
return useMutation<TaskItem, Error, UpdateTaskPayload, MutationContext>({
mutationFn: (payload) => mutateTaskStatusApi(payload),
// 1. Крок Optimistic Update
onMutate: async (newTodoPayload) => {
// Скасовуємо всі активні запити за списком завдань, щоб виключити стан гонки
await queryClient.cancelQueries({ queryKey: taskKeys.lists() });
// Запам'ятовуємо поточний стан кешу для можливого відкату
const previousTasks = queryClient.getQueryData<TaskItem[]>(taskKeys.lists());
// Оптимістично записуємо оновлене значення
if (previousTasks) {
queryClient.setQueryData<TaskItem[]>(
taskKeys.lists(),
previousTasks.map((task) =>
task.id === newTodoPayload.taskId
? {
...task,
status: newTodoPayload.nextStatus,
updatedAt: new Date().toISOString(),
}
: task
)
);
}
// Повертаємо контекст зі зліпком для onError
return { previousTasks };
},
// 2. Крок відкату при збої
onError: (_err, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.lists(), context.previousTasks);
}
},
// 3. Крок синхронізації: завжди інвалідуємо кеш після завершення
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
},
});
}Якщо оголосити const queryClient = new QueryClient() прямо в тілі React-компонента, при кожному рендері буде створюватися абсолютно новий кеш. Усі раніше завантажені дані будуть знищуватися, викликаючи нескінченні повторні мережеві запити.
// ❌ АНТИПАТЕРН: Новий QueryClient створюється на кожен рендер!
export function BadAppProvider({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient(); // КЕШ СКИДАЄТЬСЯ ПРИ КОЖНОМУ РЕНДЕРІ!
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
// ✅ ПРАВИЛЬНО: Синглтон на клієнті через модуль або useState
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (typeof window === 'undefined') {
// На сервері завжди створюємо новий інстанс на кожен запит
return makeQueryClient();
}
// У браузері перевикористовуємо синглтон
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
export function GoodAppProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}Якщо функція queryFn ігнорує переданий аргумент signal, браузер продовжить викачувати важку мережеву відповідь навіть після того, як користувач пішов зі сторінки або швидко перемкнув вкладку.
// ❌ АНТИПАТЕРН: Запит неможливо перервати при розмонтуванні
useQuery({
queryKey: ['users', id],
queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
});
// ✅ ПРАВИЛЬНО: Прокидання signal у нативний fetch
useQuery({
queryKey: ['users', id],
queryFn: ({ signal }) =>
fetch(`/api/users/${id}`, { signal }).then((res) => {
if (!res.ok) throw new Error('Network error');
return res.json();
}),
});Якщо встановити час збирача сміття (gcTime) меншим за час актуальності (staleTime), то неактивний кеш буде знищено збирачем сміття до того, як спливе термін його свіжості. При повторному монтуванні компонент покаже порожній екран зі спінером замість миттєвої віддачі з пам'яті.
// ❌ АНТИПАТЕРН: gcTime знищить кеш через 10 секунд, хоча дані свіжі 5 хвилин!
useQuery({
queryKey: ['analytics'],
queryFn: fetchAnalytics,
staleTime: 5 * 60 * 1000, // 5 хвилин
gcTime: 10 * 1000, // 10 секунд (Критична помилка конфігурації!)
});
// ✅ ПРАВИЛЬНО: gcTime ЗАВЖДИ перевищує або дорівнює staleTime
useQuery({
queryKey: ['analytics'],
queryFn: fetchAnalytics,
staleTime: 5 * 60 * 1000, // 5 хвилин
gcTime: 15 * 60 * 1000, // 15 хвилин
});Спроба змінити отриманий об'єкт data безпосередньо за посиланням призводить до прихованих багів: React не реєструє оновлення, посилання на Fiber не змінюються, а відкат стану стає неможливим.
// ❌ АНТИПАТЕРН: Пряма мутація посилання кешу
const { data: user } = useQuery({ queryKey: ['user', id], queryFn: fetchUser });
const onClick = () => {
if (user) {
user.name = 'Нове ім\'я'; // ЗАБОРОНЕНО: Мутація імутабельного кешу!
}
};
// ✅ ПРАВИЛЬНО: Незмінний запис через queryClient.setQueryData
const queryClient = useQueryClient();
const onClickGood = () => {
queryClient.setQueryData(['user', id], (oldUser: User | undefined) => {
if (!oldUser) return undefined;
return { ...oldUser, name: 'Нове ім\'я' };
});
};