Zustand-сторы: полный код и описание¶
Уровень: L3 (deep-dive) | Вверх: README.md | Раздел: ../README.md
authStore¶
Файл: frontend/src/stores/auth.ts (85 строк)
State shape¶
interface AuthState {
accessToken: string | null; // JWT access token
refreshToken: string | null; // JWT refresh token
user: User | null; // Профиль пользователя (id, name, email, avatar_media_id)
isAuthenticated: boolean; // Флаг авторизации (true если есть accessToken)
}
Actions¶
| Метод | Назначение |
|---|---|
setTokens(access, refresh?) |
Сохраняет токены в localStorage и обновляет состояние |
setUser(user \| null) |
Сохраняет/удаляет профиль в localStorage |
logout() |
Очищает все данные: localStorage, состояние, IndexedDB (clearLocalData), кэш ленты |
hydrate() |
Восстанавливает состояние из localStorage при запуске приложения |
Полный код¶
import { create } from 'zustand';
import type { User } from '../types/api';
import { clearLocalData } from '../offline/services/localData';
import { clearFeedSessionCache } from '../utils/feedSessionCache';
const TOKEN_KEY = 'plechiki_access_token';
const REFRESH_KEY = 'plechiki_refresh_token';
const USER_KEY = 'plechiki_user';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
user: User | null;
isAuthenticated: boolean;
setTokens: (access: string, refresh?: string) => void;
setUser: (user: User | null) => void;
logout: () => Promise<void>;
hydrate: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
// Сохраняет токены в localStorage и обновляет флаг isAuthenticated.
// refresh токен опционален — при refresh-запросе сервер может не возвращать новый refresh.
setTokens: (access, refresh) => {
localStorage.setItem(TOKEN_KEY, access);
if (refresh) localStorage.setItem(REFRESH_KEY, refresh);
set({
accessToken: access,
refreshToken: refresh ?? null,
isAuthenticated: true,
});
},
// Сохраняет профиль пользователя. При null — удаляет из localStorage.
setUser: (user) => {
if (user) {
localStorage.setItem(USER_KEY, JSON.stringify(user));
} else {
localStorage.removeItem(USER_KEY);
}
set({ user });
},
// Полная очистка при выходе: localStorage, Zustand state, IndexedDB, кэш ленты.
// clearLocalData() — async, очищает все таблицы Dexie (items, outfits, media, sync).
// clearFeedSessionCache() — синхронная очистка sessionStorage кэша ленты.
logout: async () => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USER_KEY);
set({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
});
try {
await clearLocalData();
} catch (error) {
console.warn('[Auth] Failed to clear local offline data on logout', error);
}
clearFeedSessionCache();
},
// Гидрация при старте приложения (вызывается в main.tsx).
// Восстанавливает токены и профиль из localStorage.
// JSON.parse обёрнут в try/catch для защиты от повреждённых данных.
hydrate: () => {
const accessToken = localStorage.getItem(TOKEN_KEY);
const refreshToken = localStorage.getItem(REFRESH_KEY);
const userJson = localStorage.getItem(USER_KEY);
let user: User | null = null;
if (userJson) {
try {
user = JSON.parse(userJson);
} catch {
localStorage.removeItem(USER_KEY);
}
}
set({
accessToken,
refreshToken,
user,
isAuthenticated: !!accessToken,
});
},
}));
Персистентность¶
Стор не использует middleware persist из Zustand. Вместо этого каждый setter вручную записывает данные в localStorage. Это даёт контроль над тем, что именно персистируется (токены и user, но не isAuthenticated), и позволяет вызывать hydrate() один раз синхронно в main.tsx до рендера.
Подписчики¶
apiClient(axios interceptor) — читаетaccessTokenдля инъекции Bearer-токенаProtectedRoute— читаетisAuthenticatedдля редиректаProfileAvatarButton— читаетuserдля отображения аватараProfilePage,SettingsPage— читаютuserдля отображения и редактирования
outfitBuilderStore¶
Файл: frontend/src/stores/outfitBuilder.ts (70 строк)
State shape¶
interface BuilderItem {
id: string; // ID вещи
title?: string; // Название вещи
media_id?: string; // ID медиа для отображения
media_url?: string; // URL медиа
}
interface BuilderSlotState {
slot: Slot; // Справочный слот (id, name, order_index, min_layers)
item?: BuilderItem; // Выбранная вещь (undefined если слот пуст)
}
interface OutfitBuilderStore {
slots: BuilderSlotState[]; // Массив слотов с вещами
title: string; // Заголовок создаваемого образа
isSelecting: boolean; // Флаг: пользователь выбирает вещь для слота
}
Actions¶
| Метод | Назначение |
|---|---|
initSlots(slots) |
Инициализирует слоты из справочника (фильтрует min_layers > 0, сортирует по order_index) |
setTitle(title) |
Устанавливает заголовок образа |
startSelecting() |
Устанавливает флаг isSelecting (для навигации на ItemSelectPage) |
setSlotItem(slotId, item) |
Привязывает выбранную вещь к слоту по ID |
clearSlot(slotId) |
Удаляет вещь из слота |
clearAll() |
Сбрасывает все слоты и заголовок |
Полный код¶
import { create } from 'zustand';
import type { Slot } from '../types/api';
export interface BuilderItem {
id: string;
title?: string;
media_id?: string;
media_url?: string;
}
export interface BuilderSlotState {
slot: Slot;
item?: BuilderItem;
}
interface OutfitBuilderStore {
slots: BuilderSlotState[];
title: string;
isSelecting: boolean;
initSlots: (slots: Slot[]) => void;
setTitle: (title: string) => void;
startSelecting: () => void;
setSlotItem: (slotId: number, item: BuilderItem) => void;
clearSlot: (slotId: number) => void;
clearAll: () => void;
}
export const useOutfitBuilderStore = create<OutfitBuilderStore>((set, get) => ({
slots: [],
title: '',
isSelecting: false,
// Инициализация слотов из справочника. Фильтрует слоты с min_layers > 0
// (только обязательные слоты). Сортирует по order_index для корректного порядка.
// Если isSelecting === true, просто сбрасывает флаг (возврат с ItemSelectPage).
initSlots: (slots) => {
const { isSelecting } = get();
if (isSelecting) {
set({ isSelecting: false });
return;
}
set({
slots: slots
.filter((s) => s.min_layers > 0)
.sort((a, b) => a.order_index - b.order_index)
.map((slot) => ({ slot })),
});
},
// Устанавливает флаг для навигации на страницу выбора вещи.
// При возврате initSlots видит isSelecting=true и не пересоздаёт слоты.
startSelecting: () => set({ isSelecting: true }),
setTitle: (title) => set({ title }),
// Иммутабельное обновление: map создаёт новый массив,
// заменяя только слот с совпавшим id.
setSlotItem: (slotId, item) =>
set((state) => ({
slots: state.slots.map((ss) =>
ss.slot.id === slotId ? { ...ss, item } : ss,
),
})),
clearSlot: (slotId) =>
set((state) => ({
slots: state.slots.map((ss) =>
ss.slot.id === slotId ? { ...ss, item: undefined } : ss,
),
})),
// Полный сброс: очищает все вещи из слотов и обнуляет заголовок.
// Сами слоты (структура) сохраняются.
clearAll: () =>
set((state) => ({
title: '',
slots: state.slots.map((ss) => ({ ...ss, item: undefined })),
})),
}));
Персистентность¶
Стор не персистируется. Состояние конструктора существует только в рамках текущей сессии конструирования (от перехода на /outfits/build до создания образа или ухода). При размонтировании страницы данные не сохраняются.
Паттерн навигации с isSelecting¶
Флаг isSelecting решает проблему навигации: при переходе OutfitBuilderPage → ItemSelectPage → OutfitBuilderPage страница конструктора ре-монтируется, что вызывает initSlots. Без флага слоты были бы пересозданы и потерялись бы выбранные вещи. С флагом — initSlots видит isSelecting: true, сбрасывает его и не трогает слоты.