Локальное состояние принадлежит одному компоненту или небольшому поддереву (открытый дропдаун, значение инпута), глобальное клиентское описывает общие данные, которыми владеет клиент (тема, сессия, корзина), а серверное хранит кэшированную копию данных бэкенда и может устареть в любой момент.
Локальное: useState или useReducer, как можно ближе к месту использования. В здоровом приложении большая часть состояния локальная.
Глобальное клиентское: источник правды находится в самом приложении. Context подходит для редко меняющихся значений, стор (Redux Toolkit, Zustand, Jotai) для общих данных, которые меняются часто.
Серверное: вы его только одалживаете, поэтому ему нужны кэширование, дедупликация, перезапрос и инвалидация: TanStack Query, RTK Query, SWR или лоадеры роутера.
Состояние в URL: фильтры, номер страницы и выбранная вкладка живут в URL, и ссылка воспроизводит экран.
Большая часть проблем возникает из-за смешивания категорий: копируя серверные данные в глобальный стор, вы вручную пишете кэш, который в библиотеке запросов уже есть.
ReactMiddle
Управление состоянием
Локальное, глобальное и серверное состояние, Context и цена его ререндеров, Redux Toolkit, Zustand, Jotai, URL как состояние, выбор стора
Закрыто вопросов
0 / 21
- 0Повторить
- 8Часто спрашивают
Вопросы
UI диспатчит экшен (обычный объект, который описывает, что произошло), стор вызывает редьюсер (state, action) => newState, а подписанные компоненты заново читают свою часть через селекторы. Редьюсеры должны быть чистыми, чтобы одинаковые состояние и экшен всегда давали одинаковый результат.
Один стор: одно дерево состояния, которое меняется только через диспатч экшенов, поэтому каждое изменение видно и воспроизводимо в Redux DevTools.
Чистота: никаких запросов к API, Date.now() или Math.random() и никаких мутаций аргументов. Побочные эффекты живут в thunk-ах или listener-ах.
Иммутабельные обновления: useSelector сравнивает результаты по ссылке; у мутированного объекта ссылка та же, поэтому UI не перерендерится, а time-travel покажет неверную историю.
В Redux Toolkit внутри createSlice вы пишете «мутирующий» код, а Immer превращает его в иммутабельное обновление.
Нет. Context служит механизмом внедрения зависимостей: он передаёт значение вниз по дереву, но ничего не хранит, не обновляет и не оптимизирует. С useState или useReducer он закрывает небольшое, редко меняющееся глобальное состояние; стор добавляет выборочные подписки, middleware и devtools.
Что даёт Context: избавляет от prop drilling. Состояние по-прежнему живёт в компоненте, и каждый потребитель перерендеривается при каждом изменении.
Что добавляет стор: компоненты подписываются на часть состояния через селекторы и перерендериваются только при её изменении; состояние живёт вне React, поэтому его могут читать обработчики событий, тесты и код без React; DevTools, middleware, персистентность.
Подходит для Context: тема, локаль, текущий пользователь, фича-флаги, внедряемая зависимость вроде API-клиента.
Подходит для стора: корзина, редактор, дашборд, где многие компоненты читают и пишут одни и те же быстро меняющиеся данные.
Серверные данные не относятся ни к тому, ни к другому: их место в кэше запросов.
Поисковый запрос и пользователь лежат в одном контексте, а значение создаётся новым объектом на каждый рендер, поэтому каждое нажатие клавиши меняет значение контекста и перерендеривает всех потребителей, включая Avatar, хотя user не изменился.
У контекста нет селекторов: даже если компонент читает только user, он подписан на всё значение.
useMemo на значении здесь не поможет, потому что query действительно меняется на каждое нажатие.
Исправление выносит query в отдельный контекст (или в локальное состояние страницы поиска, где ему обычно и место), и контекст пользователя меняется только при входе и выходе.
Если много несвязанных полей приходится держать вместе, это сигнал перенести их в стор с селекторами.
Redux Toolkit представляет собой официальный способ писать на Redux: configureStore одним вызовом подключает DevTools, thunk-и и проверки для разработки, а createSlice генерирует экшен-криэйторы и редьюсер из одного объекта, причём Immer разрешает мутирующий синтаксис.
Меньше бойлерплейта: нет констант типов экшенов, отдельных экшен-криэйторов и switch.
Immer: state.items.push(x) внутри редьюсера слайса даёт новое иммутабельное состояние.
Проверки безопасности: в режиме разработки стор предупреждает о мутациях вне редьюсеров и о несериализуемых значениях в состоянии или экшенах.
Асинхронность: createAsyncThunk для жизненного цикла запроса, listener middleware для реакций на экшены, RTK Query для серверных данных с кэшированием.
TypeScript: типы выводятся из слайсов; useSelector.withTypes<RootState>() даёт типизированные хуки без самописных обёрток.
Редьюсер переприсваивает параметр state вместо того, чтобы изменить драфт или вернуть новое значение. Переприсваивание лишь перепривязывает локальную переменную; Immer не видит ни мутации, ни возвращённого значения и оставляет старое состояние.
Внутри редьюсера слайса есть ровно два корректных способа обновления: мутировать драфт (state.items = []) или вернуть полностью новое состояние. Но не оба сразу: Immer бросает ошибку, если вы изменили драфт и вернули другой объект.
В ту же ловушку попадает todoAdded: (state, action) => state.push(action.payload): стрелочная функция возвращает результат push, то есть число. TypeScript это подсветит, а на чистом JavaScript всё упадёт в рантайме.
Правило ESLint no-param-reassign с ignorePropertyModificationsFor: ['state'] ловит переприсваивание и при этом разрешает мутации драфта.
useSelector вызывает селектор после каждого экшена и перерендеривает компонент, только если результат изменился по ссылке (===). Поэтому селектор, который каждый раз строит новый массив или объект, например state.todos.filter(...), перерендеривает компонент на каждый экшен в приложении.
Возвращайте примитивы или уже существующие ссылки (state.todos.items, state.user.name); лучше вызвать useSelector несколько раз, чем возвращать объект из полей.
Производные данные считайте мемоизированным селектором: createSelector (Reselect, его реэкспортирует Redux Toolkit) пересчитывает результат, только когда меняются входы.
shallowEqual вторым аргументом помогает, если селектор возвращает небольшой объект из полей.
В режиме разработки React Redux предупреждает, когда селектор возвращает новую ссылку для тех же входных данных («Selector unknown returned a different result when called with the same parameters»).
Мемоизированному селектору с аргументами нужен свой экземпляр на каждый компонент (useMemo(() => makeSelectItem(), [])), иначе компоненты, которые делят его, постоянно сбрасывают его кэш.
Не в редьюсерах. Thunk-и из createAsyncThunk выполняют запросы и диспатчат экшены pending, fulfilled и rejected, которые слайсы обрабатывают в extraReducers; listener middleware реагирует на экшены; а для обычной загрузки данных RTK Query заменяет и то и другое кэшем.
**createAsyncThunk**: одна асинхронная функция и три экшена жизненного цикла; rejectWithValue передаёт типизированную ошибку; promise.abort() и аргумент signal отменяют запрос.
Listener middleware (createListenerMiddleware): логика вида «когда случился экшен X, сделай Y», например аналитика, синхронизация с localStorage, поиск с дебаунсом. Он заменяет большинство применений саг.
RTK Query (createApi): эндпоинты с кэшированием, дедупликацией, инвалидацией по тегам и сгенерированными хуками. Если вы вручную пишете thunk-и для загрузки серверных данных, скорее всего, RTK Query подошёл бы лучше.
Redux-Saga и redux-observable ещё встречаются в старых кодовых базах, но новому коду они нужны редко.
RTK Query представляет собой слой загрузки и кэширования данных, встроенный в Redux Toolkit: вы описываете эндпоинты в createApi, он генерирует хуки, а кэш живёт в Redux-сторе. Выбирайте его, если приложение уже на Redux; без Redux обычным выбором по умолчанию остаётся TanStack Query.
Те же базовые идеи: кэш по эндпоинту и аргументам, дедупликация, поллинг, перезапрос при фокусе, сгенерированные хуки useGetTodosQuery и useAddTodoMutation.
Инвалидация по тегам: запросы объявляют providesTags, мутации объявляют invalidatesTags, и RTK Query перезапрашивает всё, что совпало. TanStack Query вместо этого инвалидирует по префиксу ключа запроса.
Интеграция с Redux: закэшированные данные видны в Redux DevTools, другие слайсы могут реагировать на его экшены, а onQueryStarted с updateQueryData реализует оптимистичные обновления.
Преимущества TanStack Query: нет зависимости от Redux, полноценные хуки для Suspense (useSuspenseQuery), адаптеры для других фреймворков и более широкая экосистема.
Кодогенерация из OpenAPI: @rtk-query/codegen-openapi генерирует весь API-слайс по схеме.
Храните коллекции нормализованными: каждая сущность лежит один раз в словаре entities по id, порядок хранит ids, а на другие сущности ссылаются по id. createEntityAdapter генерирует такую структуру, готовые редьюсеры вроде addOne, upsertMany и updateOne и мемоизированные селекторы.
Зачем нормализовать: вложенные копии одного пользователя в постах и комментариях расходятся, а обновление одного глубокого поля требует пересобрать всё дерево.
Поиск по id работает за O(1), а не через array.find.
Меньше ререндеров: обновление одной сущности меняет ссылку только на её объект, поэтому мемоизированные строки остальных элементов не перерендериваются.
**sortComparer** поддерживает ids отсортированным при каждой вставке.
Для серверных данных те же преимущества даёт кэш запросов с ключом по id; вручную нормализуйте состояние, которым владеет клиент или которое он активно редактирует.
Zustand представляет собой маленький стор на хуках: create возвращает хук, состояние и экшены живут в одном объекте, а компоненты подписываются через селектор. Ему не нужны провайдер, экшены и редьюсеры, и кода выходит гораздо меньше, чем с Redux Toolkit, ценой меньшего числа соглашений.
Подписка: useCart((s) => s.items.length) перерендеривает компонент, только когда меняется это значение (Object.is).
Без провайдера: стор является синглтоном уровня модуля; если нужен отдельный стор на запрос при SSR или на тест, его создают внутри Context.
Вне React: useCart.getState() и useCart.setState() работают в обработчиках событий и обычных модулях.
Middleware: persist, devtools и immer подключаются по желанию.
Выбирайте Redux Toolkit, когда команде нужна строгая структура, работа с time-travel в DevTools или RTK Query, или кодовая база уже на Redux. Zustand подходит для небольшого и среднего общего состояния без лишних церемоний.
Селектор возвращает новый объект при каждом вызове. Zustand 5 отдаёт результат в useSyncExternalStore, который сравнивает его через Object.is, поэтому React при каждой проверке видит новый снапшот и зацикливается, пока не бросит ошибку. В Zustand 4 это давало лишь лишние ререндеры.
Исправление 1: оберните селектор в useShallow; он сравнивает поля объекта на один уровень вглубь и возвращает прежнюю ссылку, если они совпали.
Исправление 2: выбирайте каждое значение отдельно; экшены, созданные в create, стабильны, поэтому useCart((s) => s.clear) никогда не вызывает рендер.
То же правило действует для useSelector в React Redux и для любого снапшота useSyncExternalStore: для неизменившихся данных возвращайте стабильную ссылку.
В Jotai состояние состоит из многих маленьких атомов, а не из одного дерева: компонент подписывается ровно на те атомы, которые читает, а производные атомы вычисляются из других, как ячейки таблицы. Он хорошо ложится на мелкозернистое взаимосвязанное состояние: редакторы, сложные формы, канвасы.
Атом: atom(0) хранит значение; useAtom работает как useState, только значение общее.
Производные атомы: atom((get) => get(a) * 2) пересчитывается, только когда меняются зависимости; записываемые производные атомы инкапсулируют логику обновления.
Точные ререндеры: каждый атом является отдельной подпиской, поэтому селекторы не нужны.
Асинхронные атомы работают с Suspense.
Компромисс: логика расползается по множеству атомов, и всё состояние сразу увидеть сложнее, чем в сторе Redux или Zustand. Recoil, более ранняя атомарная библиотека от Meta, заархивирован; поддерживаемым вариантом остаётся Jotai.
MobX делает состояние наблюдаемым (observable), запоминает, какие observable-значения каждый компонент читает во время рендера, и перерендеривает ровно эти компоненты, когда значения меняются. Redux хранит одно иммутабельное дерево состояния, которое меняют только отправленные экшены через чистые редьюсеры, а компоненты подписываются через селекторы.
Обновления: в MobX состояние мутируют внутри экшенов (this.items.push(item)); в Redux редьюсер возвращает новое состояние, которое в Redux Toolkit собирает Immer из черновика.
Подписки: observer подписывает компонент на всё, что он читает, без селекторов; useSelector перерендеривает компонент, когда выбранное значение меняется по ссылке.
Производные данные: геттер стора становится computed-значением, которое кэшируется, пока не изменятся его входы; в Redux для этого есть createSelector.
Противоположные привычки: MobX предпочитает передавать вниз объекты и читать поля как можно позже, внутри observer, который их рендерит; в Redux примитивы выбирают рано. Значение, прочитанное вне любого observer, не отслеживается, поэтому забытый observer считается классическим багом MobX.
Компромисс: сторы на классах и минимум шаблонного кода, но неявные обновления; Redux даёт единый журнал экшенов, DevTools с time travel и более строгие соглашения.
Используйте middleware персистентности, например persist в Zustand или redux-persist: он сохраняет выбранную часть состояния в localStorage (или IndexedDB) и восстанавливает её при старте. Сложности: что сохранять, как мигрировать старые данные и когда происходит регидратация.
Сохраняйте мало: partialize (Zustand) или whitelist (redux-persist) для настроек, черновиков и корзины. Не серверные данные, которые кэш запросов всё равно перезапросит, и не токены или персональные данные, которые может прочитать любой скрипт на странице.
Версионируйте и мигрируйте: поле, переименованное в новом релизе, встретит старый сохранённый JSON; version вместе с migrate преобразуют его, вместо того чтобы падать при загрузке.
Момент регидратации: с асинхронным хранилищем первый рендер видит значения по умолчанию; persist.hasHydrated() и onFinishHydration в Zustand или PersistGate в redux-persist придерживают UI, который зависит от сохранённого состояния.
SSR: на сервере нет localStorage, поэтому серверный HTML использует значения по умолчанию, а клиент применяет сохранённое состояние после гидратации, чтобы не было рассинхрона.
Ошибки хранилища: из-за лимита квоты и в некоторых приватных режимах setItem бросает исключение; приложение должно работать и без персистентности.
Кладите состояние в URL, когда пользователь ожидает, что им можно поделиться, сохранить в закладки или вернуться к нему: поисковый запрос, фильтры, сортировка, пагинация, выбранная вкладка или открытый элемент. Тогда URL становится источником правды, и компоненты читают его, а не копируют.
Выигрыш: ссылка воспроизводит точно тот же экран, кнопка «Назад» отменяет изменение фильтра, перезагрузка сохраняет состояние, а SSR может отрендерить нужную страницу.
Читайте, а не копируйте: вычисляйте значения из useSearchParams на каждом рендере; если зеркалить их в useState, появятся два источника правды, которые разъедутся.
Аккуратная история: для обновлений на каждое нажатие клавиши, например в поле поиска, заменяйте запись в истории, а для осознанных шагов вроде следующей страницы добавляйте новую.
Не кладите в URL: секреты и персональные данные (они попадают в логи и в заголовок Referer), большие объекты и кратковременный UI вроде hover или открытого дропдауна.
Парсите и валидируйте параметры: URL является пользовательским вводом, поэтому при ?page=abc нужно вернуться к значению по умолчанию.
Серверные данные представляют собой кэш того, чем владеет бэкенд, поэтому им нужны состояния загрузки и ошибки, дедупликация, перезапрос, инвалидация и сборка мусора. Положив их в глобальный стор, вы пишете этот кэш вручную, хотя в TanStack Query или RTK Query он уже реализован.
Цена ручного подхода: флаги isLoading повсюду, дублирующиеся запросы из двух компонентов, устаревшие данные после мутации, нет перезапроса при фокусе окна, гонки между ответами.
Библиотеки запросов: данные хранятся по ключу (['user', id]), один запрос делится между компонентами, данные помечаются устаревшими и перезапрашиваются, инвалидируются после мутаций, поддерживаются оптимистичные обновления.
Что остаётся в сторе: состояние, которым владеет клиент: настройки UI, черновик, шаг визарда, выделение.
После такого разделения глобальный стор во многих приложениях сжимается до нескольких маленьких слайсов, а некоторым приложениям он становится вообще не нужен.
Делите его по фичам: каждая фича владеет своим слайсом, селекторами и экшенами в своей папке, другие фичи пользуются только её публичными селекторами, а слайсы подключаются, когда загружается код фичи (combineSlices с ленивыми слайсами в Redux Toolkit 2 или отдельные маленькие сторы в Zustand).
Владение и границы
Один слайс на домен (cart, checkout), а не на страницу; фича экспортирует селекторы и экшен-криэйторы, но никогда не форму своего состояния.
Реакции между фичами идут через экшены, обработанные в extraReducers или в listener middleware, и никогда через запись одной фичи в состояние другой.
Загрузка и размер
combineSlices(...).withLazyLoadedSlices<LazySlices>() вместе с slice.injectInto(rootReducer) добавляет редьюсер, когда загружается чанк его роута, поэтому первый бандл несёт только то, что нужно первому экрану.
В Zustand принято заводить несколько маленьких сторов по фичам вместо одного гигантского; это независимые модули, и они естественно разделяются вместе с кодом.
Как не раздувать стор
Сначала вынесите серверные данные в RTK Query или TanStack Query: обычно это самая большая и запутанная часть унаследованного стора.
Вычисляйте, а не храните: суммы, отфильтрованные списки и флаги получайте из мемоизированных селекторов, чтобы у каждого факта был один источник правды.
Выводите тип корневого состояния из редьюсеров и используйте типизированные хуки (useAppSelector), чтобы переименованное поле ломало сборку, а не продакшен.
Постепенно и никогда не переписывая всё разом: сначала замените createStore на configureStore, чтобы весь старый код без изменений работал в новом сторе, затем перенесите серверные данные в RTK Query или TanStack Query, переводите редьюсеры на createSlice по мере работы над фичами, а connect и саги убирайте в последнюю очередь.
Порядок
Стор: configureStore принимает старые редьюсеры и saga middleware (в примере кода) и добавляет DevTools и проверки для разработки, которые бросают ошибку при мутации состояния и предупреждают о несериализуемых значениях. В старом коде они находят настоящие баги, поэтому исправляйте их, а не отключайте проверки.
Сначала серверные данные: саги загрузки, редьюсеры FETCH_X_REQUEST/SUCCESS/FAILURE и флаги загрузки обычно составляют самую большую часть; заменяйте их эндпоинт за эндпоинтом на RTK Query (остаётся в сторе) или TanStack Query (уходит из него).
Редьюсеры: переписывайте редьюсер на createSlice, когда меняется его фича; пока не переведены все места, откуда отправляются экшены, слайс отвечает и на старые типы экшенов через builder.addCase('ADD_TODO', ...).
Саги: оставшееся (аналитика, синхронизация, сокеты) переезжает в listener middleware; на время миграции они работают рядом.
Компоненты: заменяйте connect на useSelector и useDispatch файл за файлом; connect по-прежнему работает в React Redux 9, так что жёсткого срока нет.
Как не сломать
Сначала обновите пакеты: RTK 2 убрал объектную форму extraReducers и требует, чтобы middleware был колбэком, поэтому старый код настройки ломается сам по себе.
Покройте тестами селекторы и редьюсеры, которые собираетесь заменить.
Разрешайте в новом коде только новые паттерны, иначе легаси-часть будет расти, пока вы мигрируете.
Когда у сценария несколько шагов и события, допустимые только в части из них: оформление заказа, загрузка файла с повтором и отменой, соединение с переподключением. Автомат в каждый момент находится ровно в одном состоянии и перечисляет события, которые это состояние принимает, поэтому невозможные состояния и невозможные переходы не случаются.
Что он исправляет
Невозможные комбинации: isSubmitting, isError и isSuccess дают восемь комбинаций, из которых четыре бессмысленны; одно значение состояния допускает ровно те четыре, что существуют.
Невозможные переходы: discriminated union задаёт форму данных, но не порядок событий; автомат вдобавок игнорирует повторную отправку во время отправки или запоздалую ошибку после успеха, без if в каждом обработчике.
Эффекты привязаны к состояниям: в XState вызванный (invoked) актор запускается при входе в своё состояние и останавливается при выходе из него (актор fromPromise получает AbortSignal), а after заменяет таймеры, которыми управляют вручную.
Общая картина: statechart можно нарисовать и обсудить с дизайнерами и QA; каждый переход тестируется без рендера.
Чего он стоит
Новая модель (состояния, события, guards, actions, акторы), причём XState v5 отличается от большинства туториалов по v4.
Больше кода, чем у переключателя на useState: выпадающему списку или модальному окну автомат не нужен.
Ещё одна библиотека; для одного компонента таблица переходов в useReducer (в примере кода) даёт большую часть пользы.
Серверные данные по-прежнему живут в кэше запросов, а автомат может работать рядом со стором Redux или Zustand, не заменяя его.
Сначала классифицируйте состояние и для каждой категории возьмите самый лёгкий инструмент: локальное в компонентах, серверное в кэше запросов, состояние экрана для ссылок в URL, а стор только для оставшегося клиентского глобального состояния. Многим приложениям хватает маленького стора или никакого.
Какие вопросы задать
Кто владеет данными: сервер (кэш запросов), URL (роутер) или клиент?
Сколько компонентов их читают и пишут и как часто они меняются? Редко меняющиеся значения подходят для Context; частым обновлениям, которые читают многие компоненты, нужны селекторы.
Должно ли состояние переживать перезагрузку (persist, URL), работать офлайн, синхронизироваться между вкладками?
Типичный выбор в 2026 году
TanStack Query или RTK Query для данных API; с React Router или Next.js значительную часть закрывают лоадеры и Server Components.
Context для темы, локали, сессии и внедряемых сервисов.
Zustand для небольшого и среднего общего клиентского состояния без лишних церемоний.
Redux Toolkit для больших команд и сложных клиентских доменов (редакторы, offline-first приложения) или существующей кодовой базы на Redux.
Jotai для мелкозернистого взаимосвязанного состояния.
Команда и цена
Единообразие важнее библиотеки: один задокументированный подход лучше трёх сторов.
Прежде чем добавлять стор ради производительности, измерьте проблему с ререндерами в профайлере React DevTools.