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

Лучшие практики и контроль

Разделение Server State и Client State

MUST

В приложении обязано быть чёткое разделение:

Server State (управляется TanStack Query / SWR / RTK Query)

  • Данные из API, которые могут устаревать/меняться вне клиента
  • Примеры: пользователи, заказы, продукты, настройки из API

Client State (управляется Redux Toolkit / Zustand)

  • Синхронное состояние UI и пользовательских сценариев
  • Примеры: открыт/закрыт modal, выбранный tab, step в wizard, фильтры

То, что технически и data, и state можно хранить в state-менеджерах, не означает, что так следует делать.

FORBIDDEN

  • Хранить Server State в Redux/Zustand (данные из API, сущности, isLoading, error, lastFetchedAt и т.д.)
  • Дублировать одно и то же состояние между Server State и Client State

❌ Плохо

// НЕ храним сущности из API в zustand/redux
const useStore = create(() => ({
users: [], // ❌ Server State
isLoadingUsers: false, // ❌ Server State
usersError: null, // ❌ Server State
}));

✅ Хорошо

// Server State - в TanStack Query
const {
data: users,
isLoading,
error,
} = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});

// Client State - только UI/flow
const useUIStore = create(() => ({
selectedTab: 'active', // ✅ Client State
isFiltersOpen: false, // ✅ Client State
currentStep: 1, // ✅ Client State
}));

Где должно жить состояние

MUST

  • Состояние локально для одного UI-сценария → React local state
  • Состояние связано с серверными данными → Server State library (TanStack Query)
  • Состояние поведения/пользовательских сценариев → Client State (Redux/Zustand)
  • Derived state → селекторы/функции, а не хранилище

Bad Practices

1) Server State в Redux/Zustand

❌ Плохо

// ❌ НЕ храним сущности из API в zustand
const useStore = create(() => ({
users: [],
isLoadingUsers: false,
usersError: null,
}));

✅ Хорошо

// ✅ Server State - в TanStack Query
const { data: users, isLoading, error } = useUsersQuery();

// ✅ Client State - только UI/flow
const useUIStore = create(() => ({
selectedTab: 'active',
isFiltersOpen: false,
}));

2) Хранилище как "шина событий" (commands вместо facts)

❌ Плохо

// ❌ shouldRedirectToDashboard - это команда, а не факт
const slice = createSlice({
name: 'auth',
initialState: { shouldRedirectToDashboard: false },
reducers: {
loginSuccess: (state) => {
state.shouldRedirectToDashboard = true;
},
redirectDone: (state) => {
state.shouldRedirectToDashboard = false;
},
},
});

✅ Хорошо (храним факт)

// ✅ Факт: пользователь авторизован
// UI сам решает, что делать в своём сценарии
const slice = createSlice({
name: 'auth',
initialState: { isAuthorized: false },
reducers: {
loginSuccess: (state) => {
state.isAuthorized = true;
},
},
});

3) Derived state в хранилище

❌ Плохо

// ❌ items + total - два источника истины
const useCartStore = create(() => ({
items: [],
total: 0,
}));

✅ Хорошо (селектор/функция)

// ✅ Total вычисляется на лету
export const selectCartTotal = (items: CartItem[]) =>
items.reduce((sum, i) => sum + i.price * i.qty, 0);

Временные отклонения (MVP / small projects)

Допускается (временно)

Для Server State:

  • Использование useQuery/useMutation напрямую в UI
  • Отсутствие строгого разделения entities/*/api и entities/*/model
  • Inline query keys в пределах одного модуля

Для Client State:

  • Один простой глобальный store
  • Менее строгая композиция
  • Ограниченное смешение UI-состояния и сценариев

Обязательные ограничения (всё равно MUST)

Для Server State:

  • Server State не хранится в client state менеджерах
  • Данные не дублируются между слоями
  • UI не начинает владеть жизненным циклом серверных данных

Для Client State:

  • Client State не хранит Server State
  • Состояние не используется как "event bus"
  • Derived state не хранится
  • Один источник истины сохраняется

Когда возвращаемся к основным правилам

Для Server State:

  • Появляются межмодульные зависимости
  • Нужна инвалидация из других частей приложения
  • Растёт количество запросов и сценариев

Для Client State:

  • Рост количества UI-сценариев
  • Усложнение переходов состояния
  • Подключение Redux Toolkit или расширение Zustand

Процедуры контроля (Code Review)

Ревьюер должен убедиться, что

Общие требования

  • ✅ Чёткое разделение Server State и Client State
  • ❌ Server State не хранится в Redux/Zustand
  • ❌ Client/Server state не дублируются
  • ✅ У каждого куска состояния ровно один источник истины

Server State

  • ❌ Нет импортов fetch/axios вне shared/api
  • ❌ Нет импортов useQuery/useMutation вне entities/*/model и features/*/model (кроме MVP)
  • ✅ Управление Server State осуществляется через domain-хуки
  • ❌ Cache keys/query factories не экспортируются из модуля наружу
  • ❌ Нет импортов query keys между модулями
  • ✅ Инвалидация/оптимистические апдейты из других модулей выполняются только через семантические helper-функции

Client State

  • ✅ State manager используется осознанно, а не "по умолчанию"
  • ❌ Локальное состояние не вынесено в глобальное без причины
  • ❌ Состояние не используется для передачи команд/намерений (event bus)
  • ✅ Логика изменения состояния чистая
  • ✅ Side effects вынесены в thunks/middleware/orchestration слой feature
  • ❌ UI не импортирует внутренности хранилищ
  • ✅ Доступ к состоянию осуществляется через:
    • Селекторы
    • Доменные хуки
    • Public API модуля

Автоматизация (lint / boundary checks)

MUST

Должны быть настроены проверки, запрещающие:

  • Импорт библиотек Server State (TanStack Query/SWR/RTK Query) из UI-слоя
  • Импорт cache keys/query factories за пределы доменного модуля
  • Импорт fetch/axios вне shared/api

Реализация: eslint rules + boundary checks (например eslint-plugin-boundaries) или кастомные правила.


Чеклист для автора PR

Server State

  • Server State не хранится в Redux/Zustand
  • Domain-хуки используются вместо прямого useQuery/useMutation
  • Cache keys не экспортируются наружу
  • Инвалидация через семантические helper-функции
  • Нет импортов fetch/axios вне shared/api

Client State

  • Глобальное состояние введено осознанно (не по умолчанию)
  • Client State не содержит Server State
  • Нет дублирования состояния
  • Состояние не используется как event bus
  • Derived state вычисляется, а не хранится
  • UI использует селекторы/доменные хуки