15 мин

Стейт-менеджмент в масштабе

Глобальный vs локальный стейт, нормализация, серверный кеш и современные подходы

  • Redux
  • Zustand
  • React Query
  • Нормализация

Введение

Управление состоянием — это нервная система фронтенд-приложения. На System Design интервью от вас ждут не знание API конкретной библиотеки, а понимание когда, зачем и какое состояние нужно хранить, и какие инструменты для этого подходят. В масштабном приложении состояние бывает принципиально разных типов, и каждый тип требует своего подхода.

Типы состояния

Первый шаг — классификация. Любое состояние в приложении можно отнести к одному из четырёх типов:

  • Server state (серверный) — данные, источник правды для которых живёт на бэкенде: список товаров, профиль пользователя, комментарии. Эти данные асинхронны, могут устареть и нуждаются в синхронизации.
  • Client state (клиентский) — данные, которые существуют только на клиенте: открыта ли модалка, тема интерфейса, текст в форме до отправки. Источник правды — сам клиент.
  • URL state — фильтры, сортировка, номер страницы, поисковый запрос. Живёт в URL query parameters. Критично для SEO и работы кнопки «Назад».
  • Form state — значения полей формы, ошибки валидации, touched/dirty статусы. Обычно локален для формы.
Главная ошибка: складывать всё в один глобальный store. Это превращает приложение в монолит состояния — сложно тестировать, невозможно масштабировать, лишние перерисовки.

Дерево решений: что куда?

Используйте этот алгоритм для определения, где хранить конкретный кусок состояния:

  • Данные приходят с сервера? → React Query / SWR (серверный кэш)
  • Данные нужны в URL для SEO/шаринга? → URL search params
  • Данные нужны в >3 компонентах в разных частях дерева? → Глобальный store (Zustand, Redux Toolkit)
  • Данные нужны только одному компоненту или его детям? → useState / useReducer
  • Данные — конфигурация, тема, locale? → React Context
  • Форма с валидацией? → React Hook Form / Formik

Server State: React Query / TanStack Query

Серверное состояние — это не просто данные, а кэш удалённых данных с собственным жизненным циклом: загрузка, успех, ошибка, устаревание, инвалидация, повторная загрузка. Именно поэтому его нельзя хранить в обычном useState — вам придётся вручную управлять всеми этими состояниями.

TanStack Query (бывший React Query) решает эту задачу:

function useProducts(categoryId: string) {
  return useQuery({
    queryKey: ["products", categoryId],
    queryFn: () => fetchProducts(categoryId),
    staleTime: 5 * 60 * 1000,       // 5 минут — данные считаются свежими
    gcTime: 30 * 60 * 1000,          // 30 минут — хранить в кэше
  });
}

function useLikePost() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (postId: string) => api.likePost(postId),
    onMutate: async (postId) => {
      await queryClient.cancelQueries({ queryKey: ["posts"] });
      const previous = queryClient.getQueryData(["posts"]);
      queryClient.setQueryData(["posts"], (old) =>
        old.map(p => p.id === postId ? { ...p, liked: true } : p)
      );
      return { previous };
    },
    onError: (err, postId, context) => {
      queryClient.setQueryData(["posts"], context.previous);
    },
  });
}

Ключевые возможности: автоматический refetch при фокусе окна, дедупликация параллельных запросов, оптимистичные обновления, пагинация (useInfiniteQuery), prefetching.

Client State: Zustand

Zustand — минималистичный store без бойлерплейта. Идеален для клиентского состояния: корзина, UI-преференции, глобальные модалки.

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

interface CartStore {
  items: CartItem[];
  addItem: (product: Product) => void;
  removeItem: (productId: string) => void;
  totalPrice: () => number;
}

const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (product) =>
        set((state) => ({
          items: [...state.items, { product, quantity: 1 }],
        })),
      removeItem: (productId) =>
        set((state) => ({
          items: state.items.filter(i => i.product.id !== productId),
        })),
      totalPrice: () =>
        get().items.reduce((sum, i) => sum + i.product.price * i.quantity, 0),
    }),
    { name: "cart-storage" }
  )
);

Преимущества Zustand: нет Provider-обёртки, селекторы для точечной подписки (useCartStore(s => s.items.length)), middleware (persist, devtools, immer), размер ~1KB.

Альтернативы: Redux Toolkit, Jotai, Valtio

  • Redux Toolkit — стандарт для больших команд. Строгая архитектура, DevTools, middleware, predictable state. Оправдан при 5+ разработчиках и сложной бизнес-логике. Минус: больше бойлерплейта, чем Zustand.
  • Jotai — атомарный подход. Каждый атом — независимый кусочек стейта. Идеален для компонентов с множеством мелких независимых состояний (таблица с сортировкой по колонкам, фильтрами, выделением строк).
  • Valtio — proxy-based. Мутируете объект напрямую, рендеринг обновляется автоматически. Минимальный порог входа, но менее предсказуемо в сложных сценариях.

React Context — когда (не) использовать

Context часто злоупотребляют. Он не предназначен для часто обновляемых данных — при каждом изменении перерисовываются все потребители, даже если они используют только часть значения.

Хорошие сценарии для Context:

  • Тема (светлая/тёмная) — меняется редко
  • Локаль — меняется раз в сессию
  • Текущий пользователь — меняется при логине/логауте
  • Конфигурация feature flags — устанавливается один раз

Плохие сценарии: корзина с частым обновлением количества, список товаров, form state с onChange на каждый символ.

URL State

Фильтры, сортировка, пагинация, поисковый запрос — всё это должно жить в URL. Это критично для:

  • SEO — каждая комбинация фильтров = уникальная страница, которую может проиндексировать поисковик
  • Шаринг — ссылка /catalog?brand=apple&sort=price открывает именно то, что видел отправитель
  • Навигация — кнопка «Назад» возвращает к предыдущему набору фильтров

В Next.js App Router используйте searchParams prop в серверных компонентах и useSearchParams() + useRouter() в клиентских:

// Серверный компонент — читает фильтры из URL
export default async function CatalogPage({
  searchParams,
}: {
  searchParams: Promise<{ brand?: string; sort?: string; page?: string }>;
}) {
  const { brand, sort, page } = await searchParams;
  const products = await fetchProducts({ brand, sort, page });
  return <ProductGrid products={products} />;
}

// Клиентский компонент — обновляет фильтры в URL
"use client";
function FilterPanel() {
  const router = useRouter();
  const searchParams = useSearchParams();

  function setFilter(key: string, value: string) {
    const params = new URLSearchParams(searchParams.toString());
    params.set(key, value);
    params.delete("page");  // сброс пагинации при смене фильтра
    router.replace(`?${params.toString()}`);
  }
}

Нормализация данных

При работе с реляционными данными (посты → авторы → комментарии) критично хранить их нормализованно — каждая сущность хранится один раз, а связи выражены через ID:

// Денормализовано (плохо для больших объёмов):
posts: [
  { id: "1", author: { id: "a1", name: "Иван", avatar: "..." }, ... },
  { id: "2", author: { id: "a1", name: "Иван", avatar: "..." }, ... },
]

// Нормализовано (хорошо):
entities: {
  posts: { "1": { id: "1", authorId: "a1", ... }, "2": { id: "2", authorId: "a1", ... } },
  users: { "a1": { id: "a1", name: "Иван", avatar: "..." } },
}
postIds: ["1", "2"]

Преимущества нормализации: обновление аватара пользователя мгновенно отражается во всех постах (O(1) вместо O(n)); нет дублирования данных; проще инвалидация и патчинг.

Недостаток: сложнее начальная настройка и select-логика. Используйте createEntityAdapter из Redux Toolkit или normalizr для автоматической нормализации API-ответов.

Рекомендуемый стек

  • Server state → TanStack Query (React Query)
  • Client state → Zustand (маленькие-средние проекты) или Redux Toolkit (большие команды)
  • URL state → searchParams + useRouter
  • Form state → React Hook Form
  • Конфигурация → React Context
Для интервью: покажите, что вы понимаете разницу между server state и client state. Каталог товаров — server state (источник правды на бэкенде, нужны кэширование и инвалидация). Корзина — client state (источник правды на клиенте, синхронизируется с сервером). Смешивать их в одном store — антипаттерн.