Стейт-менеджмент в масштабе
Глобальный 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 — антипаттерн.