Перейти к содержанию

Паттерны TanStack Query

Уровень: L3 (deep-dive) | Вверх: README.md | Раздел: ../README.md

Конфигурация queryClient

Файл: frontend/src/queryClient.ts

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

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,   // 5 минут — данные считаются свежими
      networkMode: 'always',        // Выполнять запросы даже без сети (для IndexedDB)
    },
  },
});

networkMode: 'always' — ключевая настройка для offline-first: React Query не блокирует запросы при отсутствии сети, что позволяет offline-хукам читать из IndexedDB.


Все useQuery/useInfiniteQuery вызовы

Таблица вызовов

queryKey Endpoint / Источник staleTime Где используется
['feed-infinite', collection, weather] feedApi.list() 3 мин FeedPage (useInfiniteQuery)
['share', token] shareApi.getByToken() default (5 мин) ShareRecipientPage
['shared-outfits'] shareApi.getMySharedOutfits() default ProfilePage
['outfits', outfitId] outfitsApi.get() default SharePage
['items', params] IndexedDB → itemsApi.list() default useLocalItems
['items', id] IndexedDB → itemsApi.get() default useLocalItem
['items-by-ids', ids] IndexedDB → items bulk get default useLocalItemsByIds
['outfits', params] IndexedDB → outfitsApi.list() default useLocalOutfits
['outfits', id] IndexedDB → outfitsApi.get() default useLocalOutfit
['outfits-by-ids', ids] IndexedDB → outfits bulk get default useLocalOutfitsByIds
['reference', entityType] IndexedDB → referenceApi.*() default useLocalReference

Паттерн 1: Infinite Query (лента)

Файл: FeedPage.tsx

Лента образов использует курсорную пагинацию с useInfiniteQuery. Каждый запрос возвращает батч из 20 образов и next_offset для следующей страницы.

const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
  isLoading,
  refetch,
} = useInfiniteQuery({
  queryKey: ['feed-infinite', collectionCode ?? null, weather.feedContext ?? 'none'],
  queryFn: async ({ pageParam }) => {
    const resp = await feedApi.list({
      collection: collectionCode || undefined,
      weather: weather.feedContext || undefined,
      limit: BATCH,           // 20
      cursor: pageParam,
    });
    return resp.data as FeedList;
  },
  staleTime: 3 * 60 * 1000,   // 3 минуты
  gcTime: 45 * 60 * 1000,     // 45 минут (garbage collection)
  initialPageParam: undefined as string | undefined,
  getNextPageParam: (last) => last.next_offset ?? undefined,
  refetchOnMount: true,
  enabled: online,
});

Особенности: - staleTime: 3 мин — агрессивнее дефолта, т.к. лента часто обновляется - gcTime: 45 мин — долгий GC для сохранения позиции скролла при навигации - enabled: online — не выполняется в offline (лента не кэшируется локально) - Ключ содержит collectionCode и weather.feedContext — при смене фильтров кэш не переиспользуется


Паттерн 2: Offline-first с фоновым обновлением

Файл: offline/hooks/useLocalItems.ts

Основной паттерн для работы с данными гардероба. Данные читаются мгновенно из IndexedDB, затем фоново обновляются с сервера.

export function useLocalItems(params?: ItemListParams) {
  return useQuery({
    queryKey: ['items', params ?? {}],
    queryFn: async () => {
      // 1. Мгновенное чтение из IndexedDB
      const localItems = await itemsRepository.getAll(params);

      // 2. Фоновый запрос к серверу (если онлайн)
      if (networkStatus.online) {
        try {
          const resp = await itemsApi.list(params);
          const serverItems = resp.data.items;
          // 3. Обновление IndexedDB из сервера
          await itemsRepository.bulkUpsertFromServer(serverItems);
          // 4. Повторное чтение для актуальных данных
          return await itemsRepository.getAll(params);
        } catch {
          // При ошибке сети — возвращаем локальные данные
        }
      }

      return localItems;
    },
    networkMode: 'always',  // Критично: запрос выполняется даже без сети
  });
}

Последовательность: 1. IndexedDB → мгновенный результат для UI 2. Если онлайн → фоновый HTTP GET 3. Ответ сервера → bulkUpsertFromServer в IndexedDB 4. Повторное чтение из IndexedDB → актуальные данные

Аналогичный паттерн в useLocalOutfits, useLocalReference.


Паттерн 3: Простой серверный запрос

Файл: ShareRecipientPage.tsx

Прямой запрос к серверу без offline-слоя. Используется для данных, которые не нужно кэшировать локально.

const { data: shareData, isLoading } = useQuery({
  queryKey: ['share', token],
  queryFn: () => shareApi.getByToken(token!),
  enabled: Boolean(token),  // Не выполнять если token не задан
});

Все useMutation вызовы

Таблица мутаций

Мутация Endpoint Инвалидация Файл
saveMutation outfitsApi.save(id) ['outfits'], feed query key FeedPage
unsaveMutation outfitsApi.unsave(id) ['outfits'], feed query key FeedPage
claimMutation shareApi.claim(token) — (navigate) ShareRecipientPage
revokeMutation shareApi.revoke(token) ['shared-outfits'] ProfilePage
shareMutation outfitsApi.share(id, format) — (clipboard) SharePage
storyImageMutation canvas → blob — (new tab) SharePage
saveMutation (name) authApi.updateMe({name}) — (setUser) SettingsPage
passwordMutation authApi.changePassword(...) — (form reset) SettingsPage
createMutation offlineOutfitsService.create(data) ['outfits'] OutfitBuilderPage

Паттерн 4: Мутация с инвалидацией кэша

Файл: FeedPage.tsx

При сохранении образа из ленты инвалидируются два ключа: список образов пользователя и текущая лента.

const saveMutation = useMutation({
  mutationFn: async (outfit: Outfit) => {
    const realId = outfit.id;
    await outfitsApi.save(realId);
  },
  onSuccess: () => {
    // Инвалидируем кэш образов (для списка "мои образы")
    queryClient.invalidateQueries({ queryKey: ['outfits'] });
    // Инвалидируем текущий feed query
    queryClient.invalidateQueries({ queryKey: feedQueryKey });
    message.success('Образ сохранён');
  },
  onError: (error) => {
    // Специальная обработка дубликатов
    if (error?.response?.status === 409) {
      message.warning('Этот образ уже у вас');
    } else {
      message.error('Не удалось сохранить');
    }
  },
});

Паттерн 5: Мутация через offline-сервис

Файл: OutfitBuilderPage.tsx

Создание образа идёт через offline-сервис, который записывает в IndexedDB и ставит в очередь синхронизации. queryClient.refetchQueries гарантирует, что список образов обновится с учётом нового локального образа.

const createMutation = useMutation({
  mutationFn: (data: OutfitCreate) => offlineOutfitsService.create(data),
  onSuccess: () => {
    queryClient.refetchQueries({ queryKey: ['outfits'] });
    navigate('/outfits');
  },
  onError: () => {
    message.error('Не удалось создать образ');
  },
});

Паттерн 6: Условный запрос (enabled)

Файл: ProfilePage.tsx

Список shared-образов загружается только при наличии сети, т.к. эти данные не кэшируются в IndexedDB.

const { data: sharedData } = useQuery({
  queryKey: ['shared-outfits'],
  queryFn: () => shareApi.getMySharedOutfits(),
  enabled: online,  // Не выполнять в offline
});

Сводка по кэшированию

Данные Стратегия staleTime Offline
Вещи IndexedDB + фоновый refresh 5 мин Полный offline
Образы IndexedDB + фоновый refresh 5 мин Полный offline
Справочники IndexedDB + фоновый refresh 5 мин Полный offline
Лента HTTP + in-memory cache 3 мин Нет (enabled: online)
Shared-данные HTTP only 5 мин Нет (enabled: online)
Погода localStorage (30 мин TTL) Последний кэш