Проект на сорок экранов, пять разработчиков, релиз каждые две недели. К третьему месяцу половина состояния живёт в useState, вторая половина передаётся пропсами через пять уровней вложенности, а новичок тратит день, чтобы понять, откуда берётся цена в корзине. Спор о Redux, Zustand и Context API обычно ведут в терминах вкусовщины, хотя на самом деле это вопрос о частоте обновлений, размере команды и горизонте жизни проекта. Ниже разбор трёх подходов на практике: где каждый ломается, за что платит и что выбрать, когда "средний проект" перестаёт быть маленьким.

Какую часть работы по состоянию вообще решает библиотека

Прежде чем сравнивать инструменты, стоит разделить само состояние на слои, потому что инструменты решают разные задачи. Первый слой, серверный кэш: ответы бэкенда, списки товаров, профиль пользователя. У него свои правила игры, включая инвалидацию, повторные запросы и гонки ответов. Второй слой, клиентское состояние: корзина, открытые модальные окна, черновики форм, выбранные фильтры. Третий слой, конфигурация окружения: тема, локаль, текущий пользователь и его права.

Четвёртый слой стоит назвать отдельно, потому что его постоянно забывают: состояние адресной строки. Номер страницы, активные фильтры, открытая вкладка прекрасно живут в параметрах URL, обновляются кнопкой "назад" и не занимают ни байта в хранилище. Часть багов "пропали фильтры после перезагрузки" лечится не библиотекой, а переносом значений в адрес.

Ловушка в том, что команды пытаются решить первый слой спорами о втором. Серверный кэш лучше всего закрывается специализированными средствами вроде RTK Query, которые сами грузят, кэшируют и обновляют ответы. Как только кэш уходит из спора, клиентского состояния в типичном приложении остаётся на удивление мало, и выбор между Redux, Zustand и Context API становится предметным.

Второй ориентир, размер команды. Один разработчик помнит всю архитектуру в голове. Пять человек не помнят, и на сцену выходят конвенции, код-ревью и онбординг. Инструмент, который помогает новичку за неделю, ценнее инструмента, который экономит два килобайта.

Почему Context API ломается на высокой частоте обновлений состояния

Context API встроен в React и не требует ни одной зависимости, а потому всегда участвует в разговоре. Механика у него простая: провайдер раздаёт значение, потребитель читает его хуком useContext. Проблема спрятана в слове "раздаёт".

const AppContext = createContext(null);

function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const value = { user, theme };

  return (
    <AppContext value={value}>
      <DeepTree />
    </AppContext>
  );
}

Любой вызов setUser или setTheme создаёт новое значение, а новое значение заставляет React перерисовать всех потребителей контекста. Не только тех, кому нужен user, а вообще всех, от шапки до футера. Мемоизация отдельных компонентов не спасает: новый объект value одинаков для всего поддерева, и подписанные компоненты обязаны обновиться. React-команда прямо рекомендует держать в контексте редко меняющиеся вещи и дробить один контекст на несколько.

Для структурированных обновлений React-команда рекомендует связку useReducer с контекстом: редьюсер держит логику переходов в чистых функциях, контекст раздаёт результат. Шаблон рабочий и читаемый:

const CountContext = createContext(null);

function countReducer(state, action) {
  switch (action.type) {
    case 'add': return { count: state.count + 1 };
    default: throw new Error('unknown action');
  }
}

export function CountProvider({ children }) {
  const [state, dispatch] = useReducer(countReducer, { count: 0 });
  return (
    <CountContext value={{ state, dispatch }}>
      {children}
    </CountContext>
  );
}

Отсюда рабочая ниша Context API: тема оформления, локаль, инъекция зависимостей для тестов, текущая сессия. Сотни потребителей, обновления раз в смену. Корзина, которая обновляется на каждый клик, и живой поиск, дребезжащий по каждому символу, в эту нишу не влезают. Команда, которая кладёт в контекст горячее состояние, получает тормоза всего приложения из коробки и неэлегантно лечит их разрезанием контекста на десяток кусков.

Redux Toolkit как конвенция над хаосом в команде из пяти человек

Redux появился в 2015 году и научил фронтенд держать состояние в одном хранилище с предсказуемыми переходами. Классическая боль технологии, шаблонность, со временем ушла в Redux Toolkit, который сегодня и есть канонический способ работы с Redux: конфигурация хранилища одной строкой, слайсы вместо ручных редьюсеров, Immer для безопасных мутаций, DevTools из коробки. Актуальные версии, Redux 5.0, Redux Toolkit 2.x и React-Redux 9, вышли одной волной в декабре 2023 года и навели порядок в типизации.

import { createSlice, configureStore } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] },
  reducers: {
    add(state, action) {
      state.items.push(action.payload);
    },
  },
});

export const store = configureStore({
  reducer: { cart: cartSlice.reducer },
});

Сила подхода не в скорости написания, а в читаемости через год. Один стор, одна схема, один формат экшенов. Новичок открывает папку features, видит слайсы и понимает устройство приложения без экскурсии. Дорожка DevTools с отмоткой времени ловит баги, которые в консоли не видны: последовательность экшенов показывает, кто именно испортил корзину. Для серверного кэша есть RTK Query, и кэш живёт по тем же конвенциям, что и клиентское состояние:

const { data, isLoading } = useGetProductsQuery();

Подписка на срез в компоненте через useSelector, дорогие вычисления через мемоизированные селекторы createSelector из reselect:

const grandTotal = createSelector(
  [(state) => state.cart.items],
  (items) => expensiveWeightedTotal(items)
);

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

Цена тоже известна. Плата за предсказуемость, большее количество строк на фичу, жестковатая кривая входа и десяток килобайт в бандле против килобайта у конкурентов. Для команды из одного человека плата заметна, для команды из пяти и горизонта в два года она окупается чтением кода друг друга.

Zustand и хук без провайдера для быстрой разработки интерфейсов

Zustand идёт с другой стороны: минимум лишних формальностей, хранилище как обычный хук, никакой обёртки из провайдеров вокруг приложения. Актуальная ветка, 5.x, не тянет ни одной runtime-зависимости и весит около килобайта в сжатом виде.

import { create } from 'zustand';

export const useCart = create((set, get) => ({
  items: [],
  add: (id) => set((state) => ({ items: [...state.items, id] })),
  clear: () => set({ items: [] }),
}));

В компоненте подписка выглядит как хук с селектором:

const items = useCart((state) => state.items);
const add = useCart((state) => state.add);

Подписка идёт не "на хранилище", а на конкретный срез, и перерисовка происходит только когда срез реально изменился. Для объектов есть хелпер useShallow, сравнивающий поля, а не ссылки. Та же пятая версия требует React 18 и выше, потому что держится на общем механизме подписок useSyncExternalStore и потому не рвёт интерфейс на зависшие поддеревья при конкурентном рендеринге.

За пределами React тот же стор доступен как обычный объект, и это отдельное преимущество, часто решающее:

function validateBeforePay() {
  const items = useCart.getState().items;
  return items.length > 0;
}

Проверка корзины в обработчике платежа не заставляет рендерить компонент ради чтения значения. Философия простая: общее состояние не обязано быть глобальным событием, это просто ещё один хук. Необязательные удобства подключаются по желанию, каждое своим одним словом:

import { persist } from 'zustand/middleware';

export const useCart = create(
  persist(
    (set) => ({
      items: [],
      add: (id) => set((s) => ({ items: [...s.items, id] })),
    }),
    { name: 'cart' }
  )
);

Хранилище корзины выживает перезагрузку страницы, состояние покупки переживает вкладку. Промежуточный слой immer подключается точно так же, если синтаксис мутаций привычнее.

Производительность подписок и перерисовок у трёх подходов

На бумаге все три подхода "управляют состоянием", на графике перерисовок ведут себя по-разному. Context обновляет всех потребителей разом, без выбора. React-Redux идёт тоньше: каждый useSelector подписывает компонент на конкретный срез, а мемоизация reselect удерживает дорогие вычисления от повторов. Zustand делает то же самое без провайдера и с меньшим слоем абстракции.

Практические различия всплывают в граничных сценариях. При конкурентном рендеринге React 18 важно, чтобы подписчик видел согласованную картину между проходами: и React-Redux, и Zustand решают это через единый механизм useSyncExternalStore, а рукописные обёртки над контекстом решают, как получится. В тестах стор Zustand доступен без рендеринга вообще, стор Redux требует Provider, контекст требует провайдера с фиксированным сценарием. В DevTools у Redux исторически самая глубокая интеграция, путешествие по экшенам и дифф дерева, у Zustand панель подключается, но без отмотки истории из коробки.

Отдельного слова заслуживает серверный рендеринг. Стор Zustand создаётся в области модуля, а модуль на сервере живёт между запросами: без сброса состояние одного пользователя утекает в ответ другому, и перед рендером стор обязан инициализироваться начальными значениями заново. Redux переносит начальное состояние явным параметром preloadedState, контекст создаётся провайдером на каждый запрос. Ни один из трёх подходов не запрещает SSR, просто у каждого своя точка, где об этом надо помнить.

Размер в бандле стоит честности ради: Redux Toolkit с зависимостями тяжелее, ядро Redux и React-Redux вместе занимают несколько килобайт сжатого кода, Zustand около одного, Context API вообще ничего. На фоне современных приложений разница между десятью и одним килобайтом редко решает, но для встраиваемых виджетов и библиотек это довод.

Практические критерии выбора для проекта средней сложности

Сведение к одному ответу было бы враньём, зато критерии выбора формулируются прямо:

  1. редкие обновления, тема, локаль, права пользователя закрываются Context API без единой зависимости в проекте;
  2. обширное доменное состояние с горизонтом в годы и текучкой в команде выгоднее вести в Redux Toolkit с конвенциями и DevTools;
  3. интерфейсы с частыми локальными обновлениями, корзины, черновики, виджеты, пишутся на Zustand быстрее и рендерятся точнее;
  4. серверный кэш требует RTK Query или аналога независимо от того, что выбрано для клиентского состояния;
  5. каждый новый слой хранения обязан получить документированное правило "что сюда кладём и что сюда не кладём", иначе через полгода слои смешаются;
  6. комбинированные схемы легальны: сочетание Context API для темы, Zustand для UI и RTK Query для серверного кэша в одном проекте нормальная инженерия, а не капитуляция.

Смешанная схема вместо поиска единственного победителя

Реальный проект средней сложности редко живёт на одном инструменте, и честный ответ на вопрос из заголовка выглядит скучнее любого холивара. Тема и локальность живут в Context API, потому что обновления редкие и зависимостей не хочется. Доменные сущности и серверный кэш держатся в Redux Toolkit с RTK Query, потому что команда из пяти человек пишет код два года и должна понимать друг друга без совещаний. Мелкое общее состояние экранов собирается на Zustand, потому что оно пишется за минуты и не тянет за собой экосистему.

Опасность не в выборе, а в безвыборном единообразии. Команда, которая несёт Redux в приложение из трёх экранов, оплачивает перелёт там, где хватило бы прогулки. Команда, которая несёт Context API в редактор таблиц с сотнями обновлений в секунду, платит перерисовками всего дерева. Команда, у которой три разных стора рядом без правил, платит ревью, на котором никто не помнит, где что лежит.

Через два года проекта ценность показывает не название библиотеки, а предсказуемость: новичок за неделю находит нужную строку, баг воспроизводится по логу экшенов, перерисовки не требуют жонглирования мемоизацией. Любой из трёх инструментов доводит до этой цели, если им пользоваться по его нише. Холивар между ними, если честно, переоценён: настоящая работа не в выборе, а в границах.