Skip to main content

Client-Side Storage

Стратегия хранения данных на клиенте (TMA frontend).

Core Principle

Hybrid Storage: React Query для server state (in-memory) + Zustand persist для UI preferences (localStorage) + Custom utilities для специфичной логики.


1. Summary

Goal: Обеспечить мгновенный старт приложения без ожидания backend + offline-first UX для некритичных настроек.

Strategy:

  • React Query Cache — in-memory (НЕ localStorage) для server state
  • Zustand persist — localStorage для UI настроек
  • Custom utilities — специфичные use cases (balance tracking, banners)

Migration: 2025-12-19 — React Query cache переведён с localStorage на in-memory через bootstrap hydration.


2. localStorage Keys

Active Keys

КлючТипРазмерНазначениеКритичность
goloot-settings-storageZustand~200 bytessound, vibration, theme, onboardingGuideCompleted✅ Необходим
goloot-hintsZustand~100-500 bytesDismissed contextual hints✅ Необходим
lastKnownBalanceCustom~150 bytesОтслеживание "внешних наград" (scrap, xp, sp)✅ Необходим
goloot-last-visit-timeTimestamp~15 bytesAFK detection для smart refetch (5 min threshold)✅ Необходим
banner_hidden_{id}Dynamic~50 bytes × NСкрытые баннеры (30 min TTL)✅ Необходим

Total footprint: ~500-1000 bytes (0.5-1 KB) — очень лёгкий.

Deprecated Keys (Removed)

КлючУдалёнПричина
goloot-react-query-cache2025-12-19Переведён на in-memory через bootstrap hydration
goloot-user-storageUnknownLegacy, больше не используется

3. React Query Cache (In-Memory)

До миграции (2025-12-19)

// ❌ СТАРЫЙ подход — persist в localStorage
persistQueryClient({
queryClient,
persister: createSyncStoragePersister({
storage: window.localStorage,
}),
});

Проблемы:

  • Большой размер cache в localStorage (до нескольких MB)
  • Медленная загрузка при старте
  • Конфликты версий cache
  • Ошибки десериализации

После миграции

// ✅ НОВЫЙ подход — in-memory + bootstrap hydration
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 минут
gcTime: 30 * 60 * 1000, // 30 минут в памяти
refetchOnWindowFocus: false,
},
},
});

Стратегия:

  1. При старте → /api/bootstrap загружает всё одним запросом
  2. hydrateBootstrapData() заполняет React Query cache в памяти
  3. Вкладки используют данные из cache без дополнительных запросов
  4. При возврате → smart refetch через useAppVisibility

Преимущества:

  • ✅ Быстрый старт (нет десериализации)
  • ✅ Нет конфликтов версий
  • ✅ Меньше багов
  • ✅ Простота отладки

4. Zustand Persist

goloot-settings-storage

Файл: frontend/src/stores/settingsStore.ts

Что хранит:

{
sound: boolean, // БД + localStorage
vibration: boolean, // Только localStorage
theme: 'dark' | 'light', // Только localStorage
onboardingGuideCompleted: boolean // БД + localStorage (кеш)
}

Синхронизация:

  • sound → Backend (debounced 500ms) + localStorage
  • vibration → Только localStorage (нет в БД)
  • theme → Только localStorage (fallback на Telegram.colorScheme)
  • onboardingGuideCompleted → Backend + localStorage (кеш для быстрой проверки)

Зачем localStorage:

  • Мгновенный старт без ожидания Backend
  • Применение темы до загрузки API
  • Offline-first UX для некритичных настроек

goloot-hints

Файл: frontend/src/stores/hintsStore.ts

Что хранит:

{
dismissedHints: Record<HintId, {
dismissedAt: string,
viewCount: number
}>
}

Hint IDs:

  • achievement_intro
  • streak_milestone_3
  • raffle_sp_reached_100
  • season_active_info
  • referral_first_activated

Синхронизация:

  • Dismissal → Backend сразу (settingsService.dismissHint())
  • localStorage → Кеш для мгновенной проверки isDismissed()

Зачем localStorage:

  • Предотвращает повторный показ hints до загрузки Backend
  • Защита от спама подсказками при плохом интернете

5. Custom Utilities

lastKnownBalance

Файл: frontend/src/utils/lastKnownBalance.ts

Что хранит:

{
scrap: number,
xp: number,
sp: number,
spInitialized?: boolean,
lastSeen: string
}

Назначение:

  • Определение "внешних наград" — наград полученных вне приложения
  • При входе в HomeScreen сравнивается текущий баланс с lastKnownBalance
  • Если есть дельта → показывается TopFloatingReward

Use Cases:

  • Квизы в боте → наградили scrap → пользователь открыл TMA → видит "Вы получили награду!"
  • Реферальный бонус → начислен пока TMA был закрыт → показываем уведомление

Обновление:

  • В onSuccess всех мутаций которые меняют баланс
  • После показа награды в HomeScreen
  • После salvage в InventoryScreen

goloot-last-visit-time

Файл: frontend/src/hooks/useAppVisibility.ts

Что хранит:

timestamp (number as string)

Назначение:

  • AFK detection для smart refetch
  • Если пользователь AFK < 5 минут → invalidate только profile
  • Если AFK > 5 минут → invalidate profile + inventory + activeBuffs + season

Зачем:

  • Оптимизация сетевых запросов при возврате в приложение
  • Баланс между актуальностью данных и нагрузкой на сервер

Файл: frontend/src/components/ui/BannerSlider.tsx

Что хранит:

timestamp (number as string) для каждого скрытого баннера

TTL: 30 минут (HIDE_DURATION = 30 * 60 * 1000)

Назначение:

  • Пользователь может закрыть баннер на 30 минут
  • После истечения TTL — баннер снова показывается

Автоочистка:

// Проверка TTL при каждом рендере
const hiddenUntil = localStorage.getItem(hiddenKey);
if (hiddenUntil && Date.now() >= parseInt(hiddenUntil)) {
localStorage.removeItem(hiddenKey); // TTL истёк
}

6. Data Flow


7. Zustand vs localStorage vs Backend

Разделение ответственности

ДанныеZustand StatelocalStorageBackend DB
sound✅ (для UI)✅ (кеш)✅ (source of truth)
vibration✅ (для UI)✅ (source of truth)
theme✅ (для UI)✅ (source of truth)
onboardingGuideCompleted✅ (для UI)✅ (кеш)✅ (source of truth)
dismissedHints✅ (для UI)✅ (кеш)✅ (source of truth)
lastKnownBalance✅ (source of truth)
lastVisitTime✅ (source of truth)
banner_hidden_*✅ (source of truth)

Паттерн синхронизации

// 1. Zustand action обновляет локальное состояние
toggleSound: () => {
set((state) => ({ sound: !state.sound }));

// 2. Zustand persist автоматически сохранит в localStorage

// 3. Debounced sync с Backend (не блокирует UI)
debouncedSaveToBackend(get().saveToBackend);
}

Приоритеты загрузки:

  1. localStorage → Мгновенный старт
  2. Telegram fallback → Для темы если нет localStorage
  3. Backend → Source of truth для критичных настроек

8. Migration & Cleanup

Deprecated Keys Cleanup

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

Текущий статус: Cleanup не реализован. Deprecated ключи (goloot-react-query-cache, goloot-user-storage) больше не создаются и не читаются. Они просто остаются в localStorage у старых пользователей без какого-либо эффекта.

При необходимости (если deprecated ключи занимают значительный объём) можно добавить cleanup:

// Рекомендуемый паттерн (не реализован)
const DEPRECATED_KEYS = ['goloot-react-query-cache', 'goloot-user-storage'];
DEPRECATED_KEYS.forEach(key => localStorage.removeItem(key));

Future Migrations

При изменении структуры данных в localStorage:

  1. Добавление поля — безопасно, просто добавить default value при чтении
  2. Удаление поля — добавить в DEPRECATED_KEYS
  3. Изменение формата — миграция через version + transform функцию

Пример версионирования:

// Zustand persist поддерживает версии
{
name: 'goloot-settings-storage',
version: 2, // Инкремент при breaking change
migrate: (persistedState, version) => {
if (version === 1) {
// Миграция с v1 на v2
return { ...persistedState, newField: defaultValue };
}
return persistedState;
}
}

9. Trade-offs & Decisions

Почему НЕ localStorage для React Query?

АспектlocalStorage persistIn-memory + Bootstrap
РазмерДо нескольких MB~80 KB одним запросом
Скорость стартаМедленная десериализацияБыстрая гидратация
Конфликты версийЧастоНет (свежие данные)
Stale dataПроблема при изменениях APIВсегда актуальные
ОтладкаСложноПросто (один запрос)

Решение: In-memory cache + bootstrap hydration для server state.

Почему localStorage для Zustand persist?

ПреимуществоОбоснование
Мгновенный стартПрименяем тему до загрузки backend
Offline-firstНастройки работают без интернета
Лёгкий footprint~500 bytes vs MB для React Query
ПростотаZustand persist — одна строка кода

Решение: Zustand persist для UI preferences.

Почему custom utilities?

Use CaseПочему не ZustandПочему не React Query
lastKnownBalanceНужно вне React (TelegramContext)Не server state
lastVisitTimeПростой timestamp, не нужен storeНе server state
banner_hidden_*Динамические ключи, TTL логикаНе server state

Решение: Custom utilities для специфичных use cases.


10. Monitoring & Debugging

Development Tools

// Логирование размера localStorage
export const logLocalStorageSize = () => {
if (process.env.NODE_ENV !== 'development') return;

let totalSize = 0;
const sizes: Record<string, number> = {};

for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith('goloot-') || key === 'lastKnownBalance') {
const value = localStorage.getItem(key) || '';
const size = new Blob([value]).size;
sizes[key] = size;
totalSize += size;
}
}

console.log('[localStorage] Total:', totalSize, 'bytes');
console.table(sizes);
};

DevTools Inspection

// Посмотреть все ключи goLoot
Object.keys(localStorage)
.filter(k => k.startsWith('goloot-') || k === 'lastKnownBalance')
.forEach(k => console.log(k, localStorage.getItem(k)));

// Очистить всё кроме критичных настроек
Object.keys(localStorage)
.filter(k => !['goloot-settings-storage', 'goloot-hints'].includes(k))
.forEach(k => localStorage.removeItem(k));

  • Profile State Management — архитектура управления состоянием профиля (React Query sole source of truth)
  • Data Synchronization — стратегия синхронизации с backend
  • Overview — общая архитектура системы
  • Theme Guide — как работает theme switching