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

Управление Client State

Разница между “данными” и “состоянием”

В клиентском приложении важно различать:

Данные (data):

  • как правило, приходят с сервера
    • могут устаревать
    • могут изменяться вне клиента
    • клиент не является источником истины

Состояние (state):

  • описывает поведение интерфейса и пользовательские сценарии
  • полностью контролируется клиентом
  • не устаревает само по себе

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


Нужно ли вообще глобальное состояние

MUST

По умолчанию используется стандартный поток данных React:

  • локальное состояние компонентов (useState, useReducer)
  • передача данных через props
  • изменение состояния через callbacks

State manager нужен, если

  • состояние используется в компонентах, не связанных напрямую иерархией
  • “поднять состояние выше” приводит к разрастанию компонентов и смешиванию ответственности
  • состояние относится к уровню приложения, а не конкретного UI-элемента

Если есть сомнения - глобальное состояние вводить не нужно.


Server State vs Client State

MUST

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

Server State

  • данные из API, которые могут устаревать/меняться вне клиента
  • управляется библиотекой Server State (например TanStack Query)

Client State

  • синхронное состояние UI и пользовательских сценариев
  • источник истины - клиент
  • управляется Redux Toolkit или Zustand

FORBIDDEN

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

Инструменты Client State

Разрешенные библиотеки

  • Redux Toolkit
  • Zustand

Оба инструмента валидны, но предназначены для разных сценариев.


Как выбрать инструмент

1) Локально для одного компонента/поддерева

Используем React локально:

  • useState
  • useReducer

Пример

function Filters() {
const [isOpen, setIsOpen] = useState(false);
return <button onClick={() => setIsOpen(v => !v)}>Toggle</button>;
}

2) Глобально, но простое и ограниченное по области

Используем Zustand (bounded context)

3) Глобально, сложное и критична предсказуемость

Используем Redux Toolkit


Redux Toolkit

Когда подходит

Redux Toolkit подходит, если:

  • логика переходов состояния сложная
  • есть инварианты и зависимые изменения
  • важна предсказуемость и воспроизводимость
  • ожидается рост проекта/команды
  • нужна “дисциплина по умолчанию” (архитектурные ограничения)

Правила

  • Store — единственный источник истины
  • Состояние разделено на slices
  • 1 slice = 1 зона ответственности
  • UI работает через:
    • селекторы
    • и/или доменные хуки
    • UI не знает внутреннюю структуру хранилища

Побочные эффекты

MUST

  • Reducers — чистые функции
  • Side effects выносятся в:
    • thunks
    • middleware
    • orchestration слой на уровне feature

✅ Хорошо (slice + selectors)

// features/auth/model/auth-slice.ts
import { createSlice, createSelector } from '@reduxjs/toolkit';

interface AuthState { isAuthorized: boolean; userId?: string };

const initialState: AuthState = { isAuthorized: false };

const slice = createSlice({
name: 'auth',
initialState,
reducers: {
loggedIn: (state, action) => {
state.isAuthorized = true;
state.userId = action.payload.userId;
},
loggedOut: (state) => {
state.isAuthorized = false;
state.userId = undefined;
}
}
});

export const authReducer = slice.reducer;
export const authActions = slice.actions;

export const selectAuth = (root: any) => root.auth as AuthState;
export const selectIsAuthorized = createSelector(selectAuth, (s) => s.isAuthorized);

Zustand

Когда подходит

Zustand подходит, если:

  • состояние простое
  • логики немного
  • область применения можно жёстко ограничить (bounded state contexts)
  • нет необходимости в сложной модели переходов состояния

Zustand требует архитектурных ограничений, иначе быстро превращается в неуправляемое глобальное состояние.

Правила Zustand (MUST)

FORBIDDEN

  • глобальный singleton-store, доступный из любой точки приложения

Допустимые модели

Примеры:

Модель A: Изолированное хранилище через React Context (рекомендуется)

✅ Хорошо: наружу экспортируем только Provider и доменные хуки, сам store-экземпляр не экспортируем.

// features/checkout/model/checkoutStore.ts
import { createContext, useContext, ReactNode } from 'react';
import { createStore } from 'zustand/vanilla';
import { useStore } from 'zustand';

interface CheckoutState {
step: 'cart' | 'delivery' | 'payment';
setStep: (step: CheckoutState['step']) => void;
};

const createCheckoutStore = () =>
createStore<CheckoutState>((set) => ({
step: 'cart',
setStep: (step) => set({ step })
}));

const CheckoutStoreContext = createContext<ReturnType<typeof createCheckoutStore> | null>(null);

export function CheckoutStoreProvider({ children }: { children: ReactNode }) {
const store = createCheckoutStore();

return <CheckoutStoreContext.Provider value={store}>{children}</CheckoutStoreContext.Provider>;
}

export function useCheckoutStep() {
const store = useContext(CheckoutStoreContext);

if (!store) {
throw new Error('CheckoutStoreProvider is missing');
}

return useStore(store, (s) => s.step);
}

export function useCheckoutActions() {
const store = useContext(CheckoutStoreContext);

if (!store) {
throw new Error('CheckoutStoreProvider is missing');
}

return useStore(store, (s) => ({ setStep: s.setStep }));
}

Модель B: Ограниченное “общее” хранилище (если нужно)

MUST

  • запрет доступа к root state напрямую
  • у каждого логического блока свой Public API (селекторы/хуки)

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

MUST (правила размещения)

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

Bad Practices

1) Server State в Redux/Zustand

❌ Плохо

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

✅ Хорошо

  • users живут в TanStack Query / SWR
  • в client state остаётся только UI/flow (например выбранный tab, открыто/закрыто, step)

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

❌ Плохо

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

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

// факт: пользователь авторизован
// UI сам решает, что делать в своём сценарии
state.isAuthorized = true;

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

❌ Плохо

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

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

export const selectCartTotal = (items: CartItem[]) =>
items.reduce((sum, i) => sum + i.price * i.qty, 0);

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

Допускается

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

Обязательные ограничения

  • Client State не хранит Server State
  • тояние не используется как “event bus”
  • derived state не хранится
  • один источник истины сохраняется

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

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

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

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

  • state manager используется осознанно, а не “по умолчанию”
  • локальное состояние не вынесено в глобальное без причины
  • в Client State не хранится Server State:
    • данные из API
    • сущности сервера
    • isLoading/error/lastFetchedAt серверных запросов
    • client/server state не дублируются
    • у каждого куска состояния ровно один источник истины
    • состояние не используется для передачи команд/намерений
    • логика изменения состояния чистая
    • side effects вынесены в thunks/middleware/orchestration слой feature
    • UI не импортирует внутренности хранилищ
    • доступ к состоянию осуществляется через:
      • селекторы
      • доменные хуки
      • Public API модуля