ADR #007: Typed Query Keys
Статус: Принято Дата: 2025-02-22 Контекст: React Query cache — untyped key-value store
Проблема
React Query cache — нетипизированное key-value хранилище. getQueryData<T>(key) — это фактически cast, TypeScript не проверяет что T соответствует реальным данным по этому ключу. Query keys задавались inline массивами (['profile'], ['achievements']) в нескольких местах, что привело к трём багам:
Баг 1: Achievements — key mismatch + type mismatch
useAchievementClaim.ts использовал ключ ['achievements'] для optimistic update, но useAchievements хук создавал ключ ['achievements', params] (где params = undefined). React Query нормализует это в ['achievements', undefined] — разные cache entries. Optimistic update записывал данные в ключ, который никто не читал.
Дополнительно: claim читал кэш как { success: boolean; data: Achievement[] }, хотя queryFn возвращал Achievement[] напрямую (уже unwrapped). old.data.map(...) молча падал, потому что old — массив, а не объект.
Баг 2: Quests — type imprecision
useQuestManagement.ts читал кэш как QuestWithUI[], но кэш хранил Quest[] (raw API тип). Работало "случайно", потому что enrichQuestForUI вызывался сразу после, но generic — ложь, которая могла привести к тонким багам.
Баг 3: Streak — bootstrap mismatch
Bootstrap hydration записывал streak данные в ['streak'], но хук useStreak читал из ['streak', 'stats'] и ['streak', 'leaderboard']. Гидратированные данные никогда не использовались — хук всегда делал запрос к API.
Решение
Single Source of Truth: Каждый React Query хук экспортирует query key constant(ы) и data type alias. Все cache операции (bootstrap, mutations, invalidations) импортируют из источника.
Паттерн
// useProfile.ts — объявление
export const PROFILE_QUERY_KEY = ['profile'] as const;
export type ProfileData = UserProfile;
// useAchievements.ts — параметризованный ключ
export const achievementsQueryKey = (params?: GetAchievementsParams) =>
['achievements', params] as const;
export type AchievementsData = Achievement[];
// bootstrapHydration.ts — потребитель
import { PROFILE_QUERY_KEY, type ProfileData } from '../hooks/useProfile';
import { achievementsQueryKey, type AchievementsData } from '../hooks/useAchievements';
queryClient.setQueryData<ProfileData>(PROFILE_QUERY_KEY, data.profile);
queryClient.setQueryData<AchievementsData>(achievementsQueryKey(), data.achievements);
Конвенция
| Тип ключа | Паттерн | Пример |
|---|---|---|
| Статический | export const X_QUERY_KEY = [...] as const | PROFILE_QUERY_KEY |
| Параметризованный | export const xQueryKey = (params?) => [..., params] as const | achievementsQueryKey(params) |
| Data type | export type XData = ActualReturnType | ProfileData, QuestsData |
Покрытие
24 React Query хука экспортируют query key constant(ы): profile, quests, achievements, inventory, streak, season, cases, daily spins, banners, raffle, free opens, case cooldowns, active buffs, referrals, craftable items, titles, boostPass, maintenance, consolationPreview, consolationRewards, referralLeaderboard, referralWelcome, referrerRewards, liveFeed.
Несколько хуков всё ещё используют inline query keys без экспорта констант:
useBoostLeaderboard—['boostLeaderboard', limit]useBootstrap—['bootstrap']useHistory—['history', 'recent', filters]usePromoCode—['promo-code-history']— переименован вuseSpinsuseDailySpins, экспортируетDAILY_SPINS_QUERY_KEYuseWithdrawReadiness—['withdraw-readiness', itemId]useUserProfile— использует['profile']inline (хотяPROFILE_QUERY_KEYэкспортируется изuseProfile)useBoostPass(useShuffleData) —['shuffleData', milestoneId](вторичный query внутри файла)
Также useDirectPromoPrompt и usePromoCodePrompt определяют key constants локально, но не экспортируют их.
Utility-хуки useAppVisibility и useSalvageForRewards используют inline keys для invalidation, но сами не определяют queries.
Альтернативы (отклонены)
Query Key Factory (библиотека)
Пакеты типа @lukemorales/query-key-factory предлагают декларативную фабрику ключей. Отклонено: добавляет зависимость ради простой конвенции. Exported constants из хуков — проще и достаточно.
Zod validation на cache reads
Валидировать данные при getQueryData через Zod. Отклонено: runtime overhead на каждое чтение кэша. Типизация через generics достаточна для compile-time safety.
Последствия
- Compile-time safety: TypeScript ловит key mismatches и type mismatches
- Единый источник: Изменение формата данных хука автоматически затрагивает всех потребителей
- Bootstrap корректность: Streak данные (stats + leaderboard) и referral leaderboard гидратируются в правильные ключи
- Конвенция: Новые хуки обязаны экспортировать
*_QUERY_KEY+*Data
Related
- Data Synchronization — архитектура синхронизации и гидратации
- ADR #005: Profile State Management — React Query как единственный source of truth для профиля
- Client-Side Storage — стратегия in-memory cache