Profile State Management
Архитектура хранения и синхронизации данных профиля на фронтенде.
Scope: Только frontend state management. Backend API — см. Profile.
1. Summary
Goal: Единственный источник правды для данных профиля (балансы, XP, статистика) — React Query cache. Zustand хранит только UI-ephemeral state (анимации наград, уведомления баффов).
User Value: Устранение дубликатов reward-анимаций, мгновенное обновление баланса при мутациях, надёжное отслеживание внешних наград.
2. Problem Statement
Исходная архитектура (Dual Source of Truth)
Данные профиля одновременно хранились в трёх местах:
| Хранилище | Что хранило | Проблема |
|---|---|---|
React Query (['profile']) | Server state (GET /profile) | Единственный fetch, но не единственный writer |
Zustand (useUserStore.profile) | Копия server state + addScrap/addXp методы | Дублирование React Query → рассинхрон |
localStorage (lastKnownBalance) | Snapshot баланса для обнаружения внешних наград | Continuous monitoring → ложные срабатывания |
Корневая проблема
HomeScreen запускал непрерывный useEffect, сравнивающий текущий баланс с localStorage на каждом рендере. Мутации внутри приложения (claim квеста, salvage) изменяли баланс → HomeScreen детектировал "дельту" → показывал дубликат reward-анимации, хотя mutation hook уже показал свою.
Симптомы:
- Двойные TopFloatingReward анимации
- Stale balance displays
- Fragile sync logic размазанная по 20+ файлам
- Hack-функции (
markRewardAsShown,wasRewardShownForBalance) для подавления симптомов
3. Target Architecture
┌─────────────────────────────────────────────────────────┐
│ React Query ['profile'] ← SOLE source of truth │
│ - Все чтения через useProfile() или getProfileFromCache│
│ - Все записи через setQueryData (optimistic) │
│ - Server sync через refetch/invalidation │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Zustand (useUserStore) ← UI-only ephemeral state │
│ - topFloatingReward / rewardQueue │
│ - topFloatingBuff │
│ - pendingReferralBonus │
│ - NO profile data │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ localStorage (lastKnownBalance) ← ONE-TIME startup │
│ - Snapshot баланса после каждой мутации │
│ - Проверка дельты ОДИН РАЗ при запуске приложения │
│ - Не мониторится непрерывно │
└─────────────────────────────────────────────────────────┘
Разделение ответственности
| Данные | React Query | Zustand | localStorage |
|---|---|---|---|
scrap, xp, level | ✅ Source of truth | ❌ | Snapshot |
scrapFromQuizzes, etc. | ✅ Source of truth | ❌ | ❌ |
topFloatingReward | ❌ | ✅ Source of truth | ❌ |
rewardQueue | ❌ | ✅ Source of truth | ❌ |
topFloatingBuff | ❌ | ✅ Source of truth | ❌ |
pendingReferralBonus | ❌ | ✅ Source of truth | ❌ |
lastKnownBalance | ❌ | ❌ | ✅ Source of truth |
4. ADR (Architectural Decisions)
Почему React Query — единственный владелец профиля?
Проблема: Дублирование профиля в Zustand приводило к рассинхронизации. Мутации обновляли Zustand → синхронизировали в React Query → обновляли localStorage — 3 шага вместо 1.
Решение: React Query cache как единственное хранилище. Все мутации через setQueryData. Zustand полностью очищен от profile, addScrap, addXp.
Альтернативы (отклонены):
- Zustand как source of truth — нет built-in stale time, refetch, invalidation
- Два store в sync — root cause текущих багов, не решение
Запрещено копировать данные профиля из React Query в Zustand. Это корень проблемы dual source of truth. Zustand хранит ТОЛЬКО ephemeral UI state.
Почему one-time detection вместо continuous?
Проблема: Continuous useEffect на balance changes ловил все изменения — и внутренние мутации, и внешние награды. Отличить их невозможно без хаков.
Решение: Проверка дельты один раз при запуске приложения. Внутренние мутации показывают свои анимации сами.
Последствия:
- Награды полученные во время работы приложения через внешний канал (quiz bot) — покажутся только при следующем запуске
- Это приемлемый trade-off: нет дубликатов > мгновенное уведомление
Почему mutation hooks владеют своими анимациями?
Проблема: Централизованный обработчик (HomeScreen useEffect) не знал контекст мутации — источник, тип награды, нужна ли анимация.
Решение: Каждый mutation hook сам вызывает showTopReward() после setQueryData. Никакого middleman.
Паттерн:
Mutation hook → addScrapToCache() → setQueryData + localStorage
→ showTopReward(type, oldValue, newValue)
5. Key Components
Чтение профиля
| Компонент | Путь | Назначение |
|---|---|---|
| useProfile | frontend/src/hooks/useProfile.ts | Reactive hook для компонентов (обёртка над React Query) |
| getProfileFromCache | frontend/src/utils/profileCacheUpdater.ts | Imperative access для callbacks/event handlers |
useProfile — для компонентов, которые ре-рендерятся при изменении профиля:
const { profile, isLoading, refetch } = useProfile();
getProfileFromCache — для mutation hooks, где нужен snapshot без подписки:
const current = getProfileFromCache(queryClient);
Запись профиля
| Компонент | Путь | Назначение |
|---|---|---|
| addScrapToCache | frontend/src/utils/profileCacheUpdater.ts | Добавить scrap + sync localStorage |
| addXpToCache | frontend/src/utils/profileCacheUpdater.ts | Добавить XP + sync localStorage |
| setProfileCache | frontend/src/utils/profileCacheUpdater.ts | Заменить весь профиль (из API response) |
Каждая функция атомарно:
- Обновляет React Query cache через
setQueryData - Синхронизирует
lastKnownBalanceв localStorage - Возвращает новое значение (для reward animation)
Внешние награды
| Компонент | Путь | Назначение |
|---|---|---|
| useExternalRewardDetection | frontend/src/hooks/useExternalRewardDetection.ts | One-time startup check |
Запускается один раз при загрузке HomeScreen:
- Читает профиль из React Query cache
- Сравнивает с
lastKnownBalanceиз localStorage - Если дельта > 0 → показывает TopFloatingReward
- Обновляет
lastKnownBalanceкак новый baseline
UI Ephemeral State (Zustand)
| Поле | Назначение |
|---|---|
topFloatingReward | Текущая анимация награды (type, from, to) |
rewardQueue | Очередь наград для последовательного показа |
topFloatingBuff | Уведомление об активации баффа |
pendingReferralBonus | Ожидающий реферальный бонус |
Это единственные данные в Zustand useUserStore. Профиль полностью убран.
6. Data Flow
Мутация (quest claim, case open, salvage)
Внешняя награда (quiz bot, referral bonus)
7. Edge Cases
| Ситуация | Поведение | Почему |
|---|---|---|
| Первый запуск (нет localStorage) | Сохранить текущий баланс, без анимации | Нет baseline для сравнения |
| Rapid mutations (claim + open case) | Каждый hook показывает свою анимацию через rewardQueue | Queue обеспечивает последовательность |
| Внешняя награда во время сессии | Покажется при следующем запуске | One-time detection — by design |
| Tab switch / background return | Smart refetch через useAppVisibility | Не триггерит reward detection |
| Ошибка мутации (rollback) | setQueryData откатывается через onError | React Query built-in |
useCraft без localStorage sync | Добавлен updateLastKnownBalance | Был баг — дельта накапливалась |
8. Removed Hacks
Следующие функции существовали только для подавления continuous detection бага и удалены:
| Функция | Была в | Назначение |
|---|---|---|
createBalanceHash() | lastKnownBalance.ts | Hash баланса для dedup |
wasRewardShownForBalance() | lastKnownBalance.ts | Проверка "показали ли уже" |
markRewardAsShown() | lastKnownBalance.ts | Отметка "показали" |
rewardShownForHash | LastKnownBalance interface | Хранение hash |
С one-time detection они не нужны — каждая мутация показывает свою анимацию, HomeScreen не мониторит.
9. Migration Summary
| Шаг | Что | Риск |
|---|---|---|
| 1 | Создание утилит (useProfile, profileCacheUpdater) | Низкий — новые файлы |
| 2 | Миграция читателей профиля (6 компонентов) | Низкий — замена импортов |
| 3 | Миграция mutation hooks (9 хуков) | Высокий — сложная логика |
| 4 | Удаление profile из Zustand + sync useEffect | Средний — TypeScript поймает пропуски |
| 5 | One-time detection в HomeScreen | Средний — критичный UX flow |
Шаги выполняются строго последовательно (1→2→3→4→5). Каждый шаг оставляет приложение в рабочем состоянии. Type-check после каждого шага.
Затронутые файлы (~21)
Новые файлы (3):
frontend/src/hooks/useProfile.tsfrontend/src/utils/profileCacheUpdater.tsfrontend/src/hooks/useExternalRewardDetection.ts
Модифицированные файлы (~18):
frontend/src/stores/userStore.ts— удалениеprofile,addScrap,addXpfrontend/src/hooks/useUserProfile.ts— удаление Zustand syncfrontend/src/hooks/useRewardSystem.ts— замена Zustand → profileCacheUpdaterfrontend/src/hooks/useRewardClaim.ts— замена Zustand profile accessfrontend/src/hooks/useQuestClaim.ts— замена snapshot, удалениеmarkRewardAsShownfrontend/src/hooks/useAchievementClaim.ts— аналогично useQuestClaimfrontend/src/hooks/useReferrals.ts— замена profile accessfrontend/src/hooks/useOpenCase.ts— localStorage sync в onSuccessfrontend/src/hooks/useCraft.ts— добавление missing localStorage syncfrontend/src/hooks/usePromoCode.ts— localStorage sync после refetchfrontend/src/hooks/useInventory.ts— замена Zustand profile accessfrontend/src/utils/lastKnownBalance.ts— удаление hack-функцийfrontend/src/components/screens/HomeScreen/index.tsx— one-time detectionfrontend/src/components/screens/CasePreviewModal.tsx— useProfile()frontend/src/components/screens/ReferralModal.tsx— useProfile()frontend/src/components/screens/StatisticsScreen.tsx— useProfile()frontend/src/components/screens/RaffleModal.tsx— useProfile()frontend/src/components/screens/InventoryScreen/index.tsx— useProfile()
Без изменений (используют Zustand только для UI state):
TopFloatingRewardGlobal.tsx—topFloatingRewardActiveBuffsBar.tsx—topFloatingBuffTelegramContext.tsx—setPendingReferralBonus
Related
- Profile — backend API, модель данных, бизнес-логика
- Client-Side Storage — общая стратегия хранения на клиенте
- Data Synchronization — паттерны sync с backend