О насБлогКонтакты
Разработка17 апреля 2026 г. 5 мин 2

TanStack Query v5: внутреннее устройство и production паттерны

AunimedaAunimeda
📋 Содержание

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'] });
  },
});

onMutateonError/onSuccessonSettled — этот порядок гарантирован. 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, Разработка сайтов Алматы

Читайте также

OWASP Top 10 2025: безопасность веб-приложений для казахстанского разработчикаaunimeda
Разработка

OWASP Top 10 2025: безопасность веб-приложений для казахстанского разработчика

OWASP Top 10 — это стандарт критических рисков безопасности. SQL-инъекции, сломанный контроль доступа, SSRF — каждый пункт с реальной атакой на ваш Node.js/Next.js код и конкретным исправлением. Актуально для проектов на казахстанском рынке.

Node.js vs Bun vs Deno 2026: бенчмарки и выбор runtime для продакшнaunimeda
Разработка

Node.js vs Bun vs Deno 2026: бенчмарки и выбор runtime для продакшн

Bun 1.x стабилен в production. Deno 2.0 поддерживает npm-пакеты. Node.js 22 запускает TypeScript нативно. Реальные бенчмарки производительности, сравнение инструментов и конкретные рекомендации для казахстанских разработчиков.

Чистая архитектура и DDD в Node.js: практическое руководство для productionaunimeda
Разработка

Чистая архитектура и DDD в Node.js: практическое руководство для production

Clean Architecture + Domain-Driven Design в Node.js TypeScript — без академизма. Use cases, Domain Entities, Repository Pattern, Aggregate Root. Бизнес-логика изолирована от инфраструктуры — тестируется без базы данных. Рабочий код для production.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в Казахстане. Бесплатная консультация.

Получить консультацию Все статьи