TanStack Query v5: внутреннее устройство и production паттерны
Большинство используют TanStack Query примерно так: useQuery({ queryKey: ['todos'], queryFn: fetchTodos }). Работает. Но когда начинаются вопросы — «почему запрос повторяется?», «почему кэш не обновился?», «как сделать optimistic update без багов?» — нужно понимать что происходит под капотом.
Как устроен кэш изнутри
TanStack Query хранит данные в QueryCache — по сути словаре, где ключ — сериализованный queryKey, значение — Query объект.
Каждый Query объект имеет пять состояний:
- fresh — данные актуальны, запрос не нужен
- stale — данные устарели, при следующем монтировании будет запрос
- fetching — запрос идёт прямо сейчас
- paused — запрос на паузе (нет сети)
- inactive — нет ни одного активного observer (subscribed компонента)
Переход из fresh в stale происходит через staleTime (по умолчанию 0 — мгновенно). Это объясняет, почему при переходе на вкладку браузера (window focus) данные перезапрашиваются — они уже stale.
// Данные никогда не устаревают — один запрос на всё время жизни страницы:
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: Infinity,
});
// Данные актуальны 5 минут:
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000,
});
Garbage collection: неактивные запросы удаляются из кэша через gcTime (по умолчанию 5 минут). Когда последний компонент, подписанный на query, размонтируется — начинается отсчёт.
queryKey: правило, которое экономит много нервов
queryKey — это не просто строка, это массив, и TanStack Query сериализует его для хранения. Два правила:
1. Ключ должен уникально описывать данные.
// ❌ Плохо: один ключ для всех пользователей
useQuery({ queryKey: ['user'], queryFn: () => fetchUser(userId) });
// ✅ Хорошо: userId — часть ключа
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
// ✅ Хорошо: параметры фильтрации — часть ключа
useQuery({
queryKey: ['orders', { status: 'pending', page: currentPage }],
queryFn: () => fetchOrders({ status: 'pending', page: currentPage }),
});
2. Порядок внешних элементов имеет значение, порядок в объекте — нет.
// Эти ДВА ключа — ОДНО И ТО ЖЕ (объект нормализуется):
['orders', { page: 1, status: 'pending' }]
['orders', { status: 'pending', page: 1 }]
// Эти ДВА ключа — РАЗНЫЕ:
['user', 'orders', userId]
['orders', 'user', userId]
Optimistic Updates: правильный паттерн с rollback
Стандартный пример в документации не показывает, что происходит при race condition или network error. Вот production-паттерн:
const queryClient = useQueryClient();
const updateTaskMutation = useMutation({
mutationFn: (update: { id: string; completed: boolean }) =>
api.updateTask(update),
onMutate: async (update) => {
// 1. Отменяем исходящие запросы на этот queryKey
// (чтобы они не перезаписали наш optimistic update)
await queryClient.cancelQueries({ queryKey: ['tasks'] });
// 2. Сохраняем предыдущее состояние для rollback
const previousTasks = queryClient.getQueryData<Task[]>(['tasks']);
// 3. Оптимистично обновляем кэш
queryClient.setQueryData<Task[]>(['tasks'], (old) =>
old?.map((task) =>
task.id === update.id
? { ...task, completed: update.completed }
: task
) ?? []
);
// 4. Возвращаем контекст для onError
return { previousTasks };
},
onError: (err, update, context) => {
// Rollback при ошибке
if (context?.previousTasks) {
queryClient.setQueryData(['tasks'], context.previousTasks);
}
toast.error('Не удалось обновить задачу');
},
onSettled: () => {
// После успеха ИЛИ ошибки — перезапрашиваем реальные данные
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
onMutate → onError/onSuccess → onSettled — этот порядок гарантирован. onSettled всегда вызывается, поэтому invalidateQueries там — правильное место.
Prefetch в Next.js App Router (Server Components)
С Next.js App Router можно prefetch данные на сервере и передать их клиенту без дополнительного запроса:
// app/orders/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { OrderList } from './OrderList';
export default async function OrdersPage() {
const queryClient = new QueryClient();
// Данные fetched на сервере и помещены в QueryClient
await queryClient.prefetchQuery({
queryKey: ['orders'],
queryFn: () => db.order.findMany({ take: 20 }), // прямой запрос к БД!
});
return (
// dehydrate сериализует кэш, HydrationBoundary передаёт его клиенту
<HydrationBoundary state={dehydrate(queryClient)}>
<OrderList />
</HydrationBoundary>
);
}
// components/OrderList.tsx (Client Component)
'use client';
import { useQuery } from '@tanstack/react-query';
export function OrderList() {
const { data: orders } = useQuery({
queryKey: ['orders'],
queryFn: fetchOrders, // этот запрос НЕ будет выполнен — данные уже в кэше!
});
return <ul>{orders?.map(o => <li key={o.id}>{o.id}</li>)}</ul>;
}
Нет loading state. Нет дополнительного network round-trip. Данные приходят с HTML.
Infinite Queries: правильная виртуализация
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['products', { category }],
queryFn: ({ pageParam }) => fetchProducts({ cursor: pageParam, limit: 20 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor, // null = конец
});
// data.pages — массив страниц. Нужно выравнивать для рендера:
const allProducts = data?.pages.flatMap(page => page.items) ?? [];
Для больших списков — виртуализация с @tanstack/virtual:
import { useVirtualizer } from '@tanstack/react-virtual';
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: hasNextPage ? allProducts.length + 1 : allProducts.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
});
// Загружаем следующую страницу при приближении к концу:
useEffect(() => {
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
if (!lastItem) return;
if (lastItem.index >= allProducts.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage]);
Кастомные хуки: правильная абстракция
Не вызывайте useQuery напрямую в компонентах — выносите в хуки. Причины: переиспользование, централизованная обработка ошибок, единственное место изменения queryKey.
// hooks/useOrders.ts
export function useOrders(filters: OrderFilters) {
return useQuery({
queryKey: orderKeys.list(filters),
queryFn: () => ordersApi.getList(filters),
staleTime: 2 * 60 * 1000,
select: (data) => ({
items: data.items,
total: data.total,
// Трансформация данных здесь, а не в компоненте
}),
});
}
// Фабрика ключей — единственное место определения ключей:
export const orderKeys = {
all: ['orders'] as const,
lists: () => [...orderKeys.all, 'list'] as const,
list: (filters: OrderFilters) => [...orderKeys.lists(), filters] as const,
detail: (id: string) => [...orderKeys.all, 'detail', id] as const,
};
// Инвалидация всех list queries одной строкой:
queryClient.invalidateQueries({ queryKey: orderKeys.lists() });
Aunimeda строит production React/Next.js приложения для казахстанского рынка. Обсудим проект.
Смотрите также: tRPC + Zod типобезопасность, Next.js 15 Server Components, Разработка сайтов Алматы