Skip to main content

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 constPROFILE_QUERY_KEY
Параметризованныйexport const xQueryKey = (params?) => [..., params] as constachievementsQueryKey(params)
Data typeexport type XData = ActualReturnTypeProfileData, 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']
  • useSpins — переименован в useDailySpins, экспортирует DAILY_SPINS_QUERY_KEY
  • useWithdrawReadiness['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