Перейти к основному содержимому

Правила Frontend State Management

Назначение

Документ задаёт стандарты управления состоянием в frontend-приложениях Pin: разделение server state (TanStack Query) и client state (Zustand), организация stores, providers, query keys, mutations, формы (TanStack Forms + Zod) и таблицы (TanStack Tables). Применяется ко всем SPA, в первую очередь к pin-admin.

Базовый стек

СлойТехнологияВерсияНазначение
Server stateTanStack Query (@tanstack/react-query)актуальнаяКэш, фоновое обновление, optimistic updates, query invalidation
Client stateZustandактуальнаяUI-состояние, селекторы, persistence
FormsTanStack Form (@tanstack/react-form)актуальнаяУправление формами, валидация, submit lifecycle
ValidationZodактуальнаяSchema-driven validation, runtime type guards
TablesTanStack Table (@tanstack/react-table)актуальнаяServer-side sorting/filtering/pagination, column visibility
HTTP clientAxiosактуальнаяБазовый клиент, interceptors, retry
RouterTanStack Router (@tanstack/react-router)актуальнаяFile-based routing, search params, loader integration

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

Тип состоянияХранениеПримеры
Server stateTanStack QueryСписки каталогов, текущая CatalogSchema, записи, экспорты, отчёты, нотификации
User sessionZustand persisted (localStorage)JWT, текущий tenant, текущий facility, locale, theme
Ephemeral UIZustand non-persistentModal stack, command palette, toast queue, pending changes
Form stateTanStack Form (внутри компонента)Текущие значения, ошибки, dirty, submission state
URL stateTanStack Router search paramsФильтры таблиц, сортировка, page/cursor

Правило: если данные приходят с backend и могут устареть — TanStack Query. Если это локальный UI-флаг — Zustand. Если это контракт с URL (deep linking) — search params.

Организация Zustand stores

src/shared/stores/
auth-store.ts // current user, JWT, expiration, refresh
tenant-store.ts // current TenantId, available tenants
facility-store.ts // current FacilityId, available facilities, switch
permissions-store.ts // effective permissions кэшируются на сессию
theme-store.ts // light/dark/system, persisted
locale-store.ts // ru-RU/en-US, persisted
command-palette-store.ts // Ctrl+K state
modal-stack-store.ts // stack of open modals
notifications-store.ts // toast queue
filter-state-store.ts // ephemeral filter draft до подтверждения
pending-changes-store.ts // несохранённые изменения форм

Правила stores

  • Каждый store в отдельном файле.
  • Все stores создаются через create<TState>()((set, get) => ({ ... })).
  • Persistence через persist middleware с явным partialize (не сохранять чувствительные данные кроме JWT).
  • Selectors на уровне consumers (useStore((s) => s.field)) — никогда не useStore() без selector.
  • Side effects (API запросы) в stores запрещены — store только хранит состояние. Запросы делаются через TanStack Query / mutation; результат пишется в store.
  • Каждый store имеет свой .md файл в 04-frontends/pin-admin/07-shared/stores/<store>.md.

Шаблон store

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface ThemeState {
theme: "light" | "dark" | "system";
setTheme: (theme: "light" | "dark" | "system") => void;
}

export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: "system",
setTheme: (theme) => set({ theme }),
}),
{ name: "pin-pin.theme" }
)
);

Организация TanStack Query

Query keys

Каждое entity имеет свой *Keys factory:

// src/entities/catalog-definition/query-keys.ts
export const catalogDefinitionsKeys = {
all: ["pin", "definitions"] as const,
list: (facilityId: string, filters: ListFilters) =>
[...catalogDefinitionsKeys.all, "list", facilityId, filters] as const,
detail: (id: string) =>
[...catalogDefinitionsKeys.all, "detail", id] as const,
schemas: (id: string) =>
[...catalogDefinitionsKeys.all, "schemas", id] as const,
};

Правила query keys

  • Иерархия: [entityArea, action, ...scopes, ...filters].
  • Все ключи as const-литералы.
  • Никогда не хардкодить ключи в useQuery({queryKey: [...]}) — только через factory.
  • При мутации инвалидировать максимально точные ключи, не ["pin"].

Settings

// src/shared/lib/query-client.ts
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: (failureCount, error) =>
failureCount < 2 && !isNonRetryableError(error),
refetchOnWindowFocus: import.meta.env.PROD,
},
mutations: {
retry: 0,
},
},
});

Mutations

  • Все mutations описывают: optimistic update, disabled/loading state, confirmation modal, invalidated query keys, rollback UI, backend error mapping.
  • Mutation error mapping переводит { code, fieldPath } от backend в setFieldError формы.
  • После успеха — queryClient.invalidateQueries({ queryKey: ..., exact: false }) на затронутые ключи.

Организация TanStack Forms

Принципы

  • Каждая форма — отдельный компонент с useForm хук.
  • Zod schema лежит в src/entities/<entity>/schemas.ts или src/features/<feature>/schema.ts.
  • Schema используется одновременно для type inference (z.infer<typeof schema>) и runtime валидации.
  • Server-side ошибки маппятся на field paths через единый mapper mapServerErrorToFormFields.
  • Submission state (idle/submitting/success/error) — внутри useForm, не Zustand.

Шаблон формы

const form = useForm({
defaultValues: { displayName: "", iconExternalFileId: null },
validators: { onChange: createCatalogDefinitionSchema },
onSubmit: async ({ value }) => {
try {
await createMutation.mutateAsync(value);
} catch (e) {
mapServerErrorToFormFields(e, form);
}
},
});

Организация TanStack Tables

Принципы

  • Server-side sorting, filtering, pagination — обязательно для любых списков с потенциально >100 строк.
  • Column visibility и density сохраняются в Zustand store на пользователя+таблицу.
  • Виртуализация (@tanstack/react-virtual) обязательна для таблиц >100 строк.
  • Адаптивно: <900px — переход на cards view (см. ui-responsive-design-rules.md).

Шаблон таблицы

const table = useReactTable({
data: query.data?.items ?? [],
columns,
state: { sorting, columnFilters, pagination, columnVisibility },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onPaginationChange: setPagination,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
pageCount: query.data?.totalPages ?? -1,
});

TanStack Router

Файловая структура

src/pages/
__root.tsx // layout root
index.tsx // /
auth/
login.tsx
pin/
index.tsx // /pin
$catalogId.tsx // /pin/:catalogId
$catalogId/
records.tsx // /pin/:catalogId/records
records/$entryId.tsx // /pin/:catalogId/records/:entryId
schema.tsx

Правила

  • File-based routing с code-splitting по умолчанию.
  • Search params валидируются Zod (validateSearch).
  • Loader используется только для критичного prefetch; обычно данные грузятся через TanStack Query внутри компонента.
  • Guards (auth, role) — на уровне __root.tsx и при необходимости на конкретных route'ах через beforeLoad.

Axios client

// src/shared/lib/api-client.ts
import axios from "axios";
import { useAuthStore } from "@/shared/stores/auth-store";

export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 30_000,
});

apiClient.interceptors.request.use((config) => {
const { jwt } = useAuthStore.getState();
if (jwt) config.headers.Authorization = `Bearer ${jwt}`;
config.headers["X-Correlation-Id"] = generateCorrelationId();
return config;
});

apiClient.interceptors.response.use(
(r) => r,
async (error) => {
if (error.response?.status === 401) {
await useAuthStore.getState().refresh();
return apiClient.request(error.config);
}
return Promise.reject(mapAxiosError(error));
}
);

Правила

  • Один Axios instance на приложение.
  • Interceptors:
    • request: добавление Authorization header, X-Correlation-Id.
    • response: маппинг ошибок в типизированную ApiError, refresh-токен на 401.
  • Никаких глобальных fetch wrappers поверх Axios — только Axios.

Validation с Zod

  • Все request DTOs описаны Zod-схемами.
  • Все response DTOs (для критичных endpoints) валидируются Zod на boundary API adapter-а.
  • Schema именуются как <purpose>Schema: createCatalogDefinitionSchema, updateFacilitySchema.
  • Reuse через .pick(), .omit(), .extend().

Запрещено

  • Redux, MobX, Recoil в новом коде.
  • useContext для хранения состояния, которое можно положить в Zustand или TanStack Query.
  • useEffect с фетчем данных — только TanStack Query.
  • Хранение JWT в sessionStorage или cookies без httpOnly (если cookie — httpOnly, SameSite=Strict, Secure).
  • Импорт react-query (старая версия) — только @tanstack/react-query.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы