Паттерны 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) | — | Последний кэш |