В современной архитектуре веб-приложений одной из ключевых инженерных ошибок долгое время являлась попытка управлять серверными данными с помощью инструментов клиентского состояния (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: 'Новое имя' };
});
};