Выбор стейт-менеджера без религии: useState, Zustand, Redux

Фронтенд4 мин чтения
  • #react
  • #state-management
  • #zustand
  • #redux
  • #react-query

«Какой стейт-менеджер выбрать?» — вечный вопрос, на который обычно отвечают любимым инструментом. Но выбирать стоит не по моде и не по холиварам в комментариях, а по типу состояния, с которым имеете дело. Большая часть боли со стейтом — от того, что пытаются засунуть всё в один инструмент. Разберём три категории и подход к каждой.

Категория 1. Локальный UI-стейт

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

function SearchBox() {
  const [query, setQuery] = useState('');
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Точка. useState (или useReducer для сложной логики переходов) — единственный правильный ответ. Не нужно тянуть Redux, MobX или глобальный стор ради одного поля. Если состояние локально — держите его локально. Когда окажется, что оно нужно ещё кому-то — поднимите наверх или вынесите в общий стор. Не преждевременно обобщайте.

Категория 2. Серверный стейт — отдельный зверь

Самая частая ошибка — хранить данные с API в Redux/Zustand/Context вручную: написать экшен fetchUsers, в нём fetch, потом dispatch(setUsers(data)), плюс флаги loading/error, плюс инвалидация кеша. Это путь к боли: дублирование, гонки запросов, устаревший кеш, ручная дедупликация.

Серверный стейт качественно отличается от клиентского: у него есть источник истины на сервере, он асинхронный, его надо рефетчить и инвалидировать. Для него существуют специализированные библиотеки:

  • TanStack Query (React Query) — де-факто стандарт. Кеш по ключу запроса, автоматический рефетч (на фокус окна, по интервалу, после мутации), дедупликация одновременных запросов, optimistic updates, бесшовная пагинация.
  • SWR — проще и легче, от Vercel; основной набор возможностей похож.
  • RTK Query — если уже на Redux Toolkit, встроен в него.
function UserList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json())
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorView />;
  return data.map(u => <UserRow key={u.id} user={u} />);
}

Никаких экшенов, никаких флагов, никакого бойлерплейта. Кеш, рефетч, инвалидация — на стороне библиотеки. Серверные данные не должны лежать в клиентском сторе вручную.

Категория 3. Глобальный клиентский стейт

Состояние, которое нужно во многих местах приложения и не связано с сервером: тема оформления, авторизация пользователя (клиентская часть), настройки UI, выбранный язык.

Здесь 90% случаев покрывают Zustand или Jotai — современные минималистичные сторы без экшенов, редьюсеров и провайдеров:

import { create } from 'zustand';

const useThemeStore = create((set) => ({
  theme: 'light',
  toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
}));

// В любом компоненте, без пропсов и провайдеров
function ThemeToggle() {
  const theme = useThemeStore((s) => s.theme);
  const toggle = useThemeStore((s) => s.toggleTheme);
  return <button onClick={toggle}>{theme}</button>;
}

Подписка на конкретный срез (useThemeStore(s => s.theme)) означает, что компонент перерендеривается только при изменении этого поля. Никакого Context.Provider, который перерендеривает всех потребителей при любом изменении.

Где Redux всё ещё оправдан

Redux (с Redux Toolkit) — не зло, просто инструмент со своей нишей. Он по-прежнему имеет смысл, когда:

  • Большие команды с жёсткими соглашениями. Единственный путь изменения состояния через экшены и редьюсеры — это самодокументируемый, предсказуемый поток. Легче онбординг, проще ревью.
  • Сложная логика обновления. Когда изменения состояния нетривиальны (асинхронные последовательности, саги, реакции на экшены разных модулей) — middleware Redux (Redux Saga, RTK Listener) решают это чище, чем ручной код.
  • Нужны devtools с time-travel. Запись всех экшенов, перемотка состояния назад — мощный инструмент отладки для сложных доменов.

Для большинства новых проектов малого и среднего размера Redux избыточен. Но он не «устарел» — он просто перестал быть дефолтом.

Антипаттерны

Дублирование серверных данных в клиентском сторе. Самая частая боль. Список пользователей хранится и в React Query (правильно), и в Zustand «для удобства» (неправильно). Два источника истины → рассинхрон → баги. Серверные данные — только в кеше React Query/SWR.

Прокидывание всего через Context. Context.Provider при изменении значения перерендеривает всех потребителей, даже тех, кому нужна другая часть контекста. Для темы это нормально (она меняется редко), для поиска или фильтров — катастрофа. Если контекст меняется часто — разбейте его или используйте Zustand с точечной подпиской.

Один гигантский стор на приложение. Когда в одном Zustand-сторе живут данные пользователя, тема, корзина, фильтры и состояние форм — это а-ля Redux-2015, только без middleware. Разделяйте по доменам: отдельный стор для темы, отдельный для UI, серверные данные в Query.

Превентивное обобщение. Создать стор «на будущее», когда состояние ещё локальное. Не делайте так. Поднимайте и обобщайте, когда реальная потребность возникнет — рефакторинг из useState в Zustand занимает пять минут.

Фреймворк выбора

Задайте три вопроса про конкретные данные:

  1. Что это за данные? — UI-стейт, серверные или глобальные клиентские.
  2. Кто их источник истины? — сервер → React Query; текущий компонент → useState; несколько компонентов → Zustand.
  3. Как часто они меняются? — редко → Context годится; часто → Zustand с точечной подпиской.

Короткое summary

Стейт-менеджмент — не один инструмент, а три разные категории. Локальный UI-стейт — useState/useReducer, точка. Серверный стейт (данные API, кеш, инвалидация) — React Query / SWR / RTK Query, не кладите их в стор вручную. Глобальный клиентский стейт (тема, авторизация, настройки) — Zustand или Jotai в большинстве случаев; Redux имеет смысл для больших команд со сложной логикой обновления и потребностью в devtools. Главные антипаттерны: дублирование серверных данных в клиентском сторе, Context для часто меняющихся данных, один стор на всё приложение.

Что почитать