Dev Highlights

Доки по разработке

This project is maintained by teniryte

2. Запросы и Query Options

TanStack Query управляет жизненным циклом сетевых запросов, кешированием и синхронизацией состояния. В v5 единый источник правды — Query Cache, а useQuery, useSuspenseQuery, useQueries и роутеры подписываются на него через наблюдателей. Любые компоненты, серверные лоадеры и фоновые задачи могут читать и прогревать один и тот же кеш.

Базовый useQuery

import { useQuery } from '@tanstack/react-query';
import { fetchTodos } from './api';

export function Todos() {
  const {
    data,
    error,
    status,       // 'pending' | 'error' | 'success'
    fetchStatus,  // 'idle' | 'fetching' | 'paused'
    refetch,
  } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    staleTime: 30_000,
  });

  if (status === 'pending') return <Spinner />;
  if (status === 'error') return <ErrorView error={error} />;

  return (
    <>
      <TodoList todos={data} />
      <button onClick={() => refetch()}>Обновить</button>
    </>
  );
}

status описывает наличие данных, а fetchStatus — сетевую активность. Это позволяет показывать «фоновые» индикаторы без скрытия данных. queryFn автоматически получает AbortSignal, meta и сам queryKey, поэтому удобно писать универсальные функции:

async function fetchTodos({ queryKey, signal, meta }: QueryFunctionContext) {
  const [_key, { userId }] = queryKey;
  const res = await fetch(`/api/users/${userId}/todos`, { signal });
  if (!res.ok) throw new Error('Failed');
  const data = await res.json();
  meta?.analytics?.track('todos.loaded', { count: data.length });
  return data;
}

Все fetch/Axios запросы следует связать с signal, иначе отменённый запрос продолжит работу впустую.

Что стоит держать в голове при первом запросе

Часто используемые опции

Опция Значение по умолчанию Назначение
queryKey Уникальный ключ (обычно массив), определяющий кеш
queryFn Асинхронная функция, возвращающая данные
enabled true Управляет тем, когда запрос активируется
staleTime 0 Время свежести данных; Infinity отключает фоновые рефетчи
gcTime (cacheTime) 5 * 60_000 Время жизни кеша без подписчиков
refetchOnWindowFocus true Авто-рефетч при фокусе вкладки
refetchOnMount true Управляет рефетчем при маунте (может быть 'always', false или функция)
refetchOnReconnect true Ре-фетч при восстановлении сети
refetchInterval false Периодический поллинг (мс или функция)
refetchIntervalInBackground false Продолжает поллинг даже без фокуса окна
retry 3 Количество повторных попыток при ошибке (false, число или функция)
retryDelay function Backoff между попытками; можно задать кастомную функцию
networkMode 'online' 'online' | 'always' | 'offlineFirst' — влияет на поведение при офлайне
suspense false Использовать ли Suspense для ожидания запроса
throwOnError false Прокидывать ли ошибку наружу (в error boundary/Suspense)
select Трансформация данных перед тем, как они попадут в компонент
placeholderData Временные данные до первого успешного ответа
initialData Статические или гидратированные данные, сразу считающиеся «успешными»
initialDataUpdatedAt Date.now() Таймстемп для initialData, чтобы управлять свежестью
keepPreviousData false Сохранять предыдущие данные пока запрашиваем новые
structuralSharing true Включает умное сравнение данных для минимизации ререндеров
notifyOnChangeProps 'auto' Тонкая настройка, какие поля из состояния вызывают обновление
refetchType 'active' 'active' | 'inactive' | 'all' — какие запросы рефетчить при invalidate
meta {} Пользовательские метаданные, попадающие в queryFn, обработчики и события

Как дружат staleTime, gcTime и авто-рефетчи

Композиция queryKey и фабрики опций

Создавайте фабрики query-опций, чтобы переиспользовать одни и те же настройки в компонентах, роутерах и при префетче:

import { queryOptions } from '@tanstack/react-query';

export const todosQueryOptions = (userId: string, filters: TodosFilters) =>
  queryOptions({
    queryKey: ['users', userId, 'todos', { filters }],
    queryFn: () => fetchTodos({ userId, filters }),
    staleTime: filters.status === 'archive' ? Infinity : 30_000,
    meta: { feature: 'todos' },
  });

Управление повторными попытками и сетью

const { data, error, isFetching } = useQuery({
  queryKey: ['report', reportId],
  queryFn: () => fetchReport(reportId),
  retry: (failureCount, error) =>
    error instanceof ValidationError ? false : failureCount < 5,
  retryDelay: failureCount => Math.min(1000 * 2 ** failureCount, 15_000),
  networkMode: 'offlineFirst', // собирает запросы в очередь, пока нет сети
  refetchOnReconnect: false,   // иначе offlineFirst сразу перезапустит
});

Отмена запросов и согласование кеша

const queryClient = useQueryClient();

async function optimisticToggle(todoId: string) {
  await queryClient.cancelQueries({ queryKey: ['todos'] });

  const previous = queryClient.getQueryData<Todo[]>(['todos']);
  queryClient.setQueryData(['todos'], old =>
    old?.map(todo =>
      todo.id === todoId ? { ...todo, completed: !todo.completed } : todo,
    ),
  );

  try {
    await updateTodo(todoId);
  } catch (error) {
    queryClient.setQueryData(['todos'], previous);
    throw error;
  } finally {
    queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'active' });
  }
}

Suspense и обработка ошибок

import { useSuspenseQuery } from '@tanstack/react-query';

function TodoListScreen() {
  const query = useSuspenseQuery({
    ...todosQueryOptions('currentUser', defaultFilters),
    suspense: true,
    throwOnError: (error, query) => query.state.dataUpdatedAt === 0,
  });

  return <TodoList todos={query.data} />;
}

useQueryState и селективные подписки

useQueryState берёт только нужные части состояния, не создавая observer в компоненте:

const name = useQueryState({
  ...userQueryOptions(id),
  select: data => data.profile.name,
  placeholderData: (prev) => prev ?? '',
});

Трекинг изменений: notifyOnChangeProps: 'tracked'

const query = useQuery({
  ...todosQueryOptions(userId, filters),
  notifyOnChangeProps: 'tracked',
});

const items = query.data;           // только доступ к data вызовет ререндер
const isFetching = query.isFetching;

Контролируемый старт через enabled

const userId = useUserId(); // может быть undefined

const user = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId!),
  enabled: Boolean(userId),
  placeholderData: () => queryClient.getQueryData(['user', 'anonymous']),
});

enabled: false полностью отключает автоматические рефетчи (включая refetchOnWindowFocus). Чтобы запустить запрос вручную, используйте refetch() или queryClient.ensureQueryData. Комбинируйте enabled с select, чтобы зависимые запросы не падали:

const audit = useQuery({
  queryKey: ['audit', project.data?.auditId],
  enabled: Boolean(project.data?.auditId),
  queryFn: () => fetchAudit(project.data!.auditId!),
  staleTime: 5 * 60_000,
});

select, placeholderData и keepPreviousData

const { data: todos, isFetching, isPlaceholderData } = useQuery({
  queryKey: ['todos', filters],
  queryFn: () => fetchTodos(filters),
  select: data => data.items,
  placeholderData: keepPreviousData, // встроенный helper
  initialDataUpdatedAt: Date.now(),  // управляем свежестью при гидрации
});

Пагинация и useInfiniteQuery

const feed = useInfiniteQuery({
  queryKey: ['feed', filters],
  queryFn: ({ pageParam = 1 }) => fetchFeed({ page: pageParam, filters }),
  getNextPageParam: (lastPage) => lastPage.next ?? undefined,
  getPreviousPageParam: (firstPage) => firstPage.prev ?? undefined,
  select: data => ({
    pages: data.pages.map(page => page.items),
    pageParams: data.pageParams,
  }),
  keepPreviousData: true,
});

Параллельные запросы: useQueries

const results = useQueries({
  queries: [
    { queryKey: ['user', id], queryFn: () => fetchUser(id) },
    { queryKey: ['posts', id], queryFn: () => fetchPosts(id), staleTime: 60_000 },
  ],
});

Каждый объект внутри queries принимает те же опции, что и useQuery. В v5 useQueries возвращает массив (или объект, если задать combine):

const profile = useQueries({
  queries: [
    userQueryOptions(id),
    todosQueryOptions(id, { status: 'active' }),
  ],
  combine: results => ({
    isPending: results.some(r => r.isPending),
    data: {
      user: results[0].data,
      todos: results[1].data ?? [],
    },
  }),
});

Prefetch-паттерны

function UserLink({ id, children }) {
  const queryClient = useQueryClient();

  const preload = () =>
    queryClient.prefetchQuery({
      ...userQueryOptions(id),
      staleTime: 5 * 60_000,
    });

  return (
    <Link
      href={`/users/${id}`}
      onPointerEnter={preload}
      onFocus={preload}
    >
      {children}
    </Link>
  );
}

Управление кешем через QueryClient

const queryClient = useQueryClient();

// Прогрев кеша
await queryClient.prefetchQuery(todosQueryOptions(userId, filters));

// Гарантированное получение данных (используется в роутерах)
const todos = await queryClient.ensureQueryData(todosQueryOptions(userId, filters));

// Оптимистичное обновление
queryClient.setQueryData(['todos'], old => [
  ...(old ?? []),
  optimisticTodo,
]);

// Инвалидация (active|inactive|all)
queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'active' });

// Ручное чтение состояния
const state = queryClient.getQueryState(['todos']);

SSR, роутеры и подготовка данных

Управление состояниями и побочными эффектами

Focus/Online менеджеры и персист кеша

import { focusManager, onlineManager } from '@tanstack/react-query';

focusManager.setEventListener((handleFocus) => {
  function onVisibility() {
    if (document.visibilityState === 'visible') handleFocus();
  }
  window.addEventListener('visibilitychange', onVisibility, false);
  return () => window.removeEventListener('visibilitychange', onVisibility);
});

onlineManager.setEventListener((setOnline) => {
  function onStatus(e: CustomNetworkEvent) {
    setOnline(e.detail === 'online');
  }
  window.addEventListener('intranet-status', onStatus);
  return () => window.removeEventListener('intranet-status', onStatus);
});

Частые паттерны

Диагностика

Быстрые лайфхаки