Централизованное состояние: React vs Redux/Effector

Что мешает организовать централизованное состояние без менеджера состояния?. Если организовать состояние механизмами реакта: контекстом, стейтом, в чем проблема? Что менеджеры состояния привносят?

Теория: что мешает без менеджера

Централизованное состояние на механизмах React обычно строится из useState/useReducer + Context. Такой подход работает, но упирается в две практические границы: (1) стоимость ререндеров и «ширина» обновлений, (2) сложность организации логики (асинхронность, кэширование, конкурентные обновления, согласованность данных между экранами).

Контекст — это механизм доставки значения «глубоко вниз», но не полноценный механизм «селективных подписок». При изменении значения контекста типичный эффект — обновления всех компонентов, которые читают этот контекст.
Это означает, что «одно большое глобальное значение» в контексте часто превращается в «перерисовку всех потребителей при любом изменении любой части объекта».

Мини-схема проблемы контекста

Если хранится единый объект:

  • GlobalProvider value={{ user, settings, cart, ... }}
  • изменился cart
  • обновились все потребители user/settings/cart/..., потому что поменялась ссылка на объект value (или его часть была пересобрана)
UI components
   |
   v  useContext(GlobalStateContext)
[consumer A: user]   [consumer B: cart]   [consumer C: settings]
        \                 |                 /
         \                |                /
          +---- Provider value changed ----+

Что дают менеджеры состояния

Точечные подписки (селекторы)

В Redux-подходе (через React-Redux hooks) типовая схема заключается в том, что компонент подписывается не на весь store целиком, а на результат селектора. Перерисовка происходит только когда результат селектора изменился.
Идея в том, что компонент «зависит» не от всего глобального объекта, а от маленького результата селектора (например, state.auth.userId), поэтому обновления становятся уже и дешевле.

Предсказуемость модели обновлений

Менеджеры состояния обычно навязывают (или поощряют) явную модель изменений: «событие/экшен → обработчик → новое состояние». Это снижает количество «скрытых» связей между компонентами и помогает сопровождать код, когда логика разрастается (появляются кэш, optimistic updates, синхронизация вкладок, восстановление состояния и т.п.).

Асинхронные операции как стандартный паттерн

При использовании только React часто получается «асинхронность размазана по компонентам» (эффекты в разных местах, ручная синхронизация загрузок, гонки запросов). Менеджеры состояния обычно предлагают стандартизированные способы: middleware/эффекты/события, централизованное место для побочных эффектов и единый подход к обработке ошибок и отмен.

Инструменты отладки и дисциплина

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

Контекст (как транспорт) и глобальное состояние (как архитектура) легко перепутать. Большой «global context» без селекции и без дисциплины обновлений часто приводит к трудно диагностируемым перерендерам и росту связности.

Как сделать централизованное состояние на React

Ниже приведён рабочий «чисто React» подход (Context + useReducer) и показано, где начинаются ограничения.

Базовый вариант: один контекст (часто проблемный)

import React, { createContext, useContext, useMemo, useReducer } from "react";

const AppStateContext = createContext(null);

const initialState = {
  user: null,
  cart: [],
  settings: { theme: "light" },
};

function reducer(state, action) {
  switch (action.type) {
    case "user/set":
      return { ...state, user: action.payload };
    case "cart/add":
      return { ...state, cart: [...state.cart, action.payload] };
    case "settings/theme":
      return { ...state, settings: { ...state.settings, theme: action.payload } };
    default:
      return state;
  }
}

export function AppStateProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  // Без useMemo объект будет новым на каждый render Provider,
  // а потребители будут обновляться чаще, чем нужно.
  const value = useMemo(() => ({ state, dispatch }), [state]);

  return (
    <AppStateContext.Provider value={value}>
      {children}
    </AppStateContext.Provider>
  );
}

export function useAppState() {
  const ctx = useContext(AppStateContext);
  if (!ctx) throw new Error("Provider is missing");
  return ctx;
}

Проблема данного подхода в том, что state — один объект, и любое изменение в нём означает новое значение value провайдера, а значит — обновления всех потребителей контекста, читающих этот контекст.
Даже если изменения касаются только cart, компонент, читающий user, всё равно может обновляться, потому что обновилось общее значение, которое передаётся через контекст.

Улучшение: разделение контекстов (сужение обновлений)

Разделение «данные» и «dispatch» уже помогает, потому что dispatch обычно стабилен.

import React, { createContext, useContext, useMemo, useReducer } from "react";

const StateContext = createContext(null);
const DispatchContext = createContext(null);

function reducer(state, action) { /* тот же reducer */ }

export function Provider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  // Состояние меняется — потребители StateContext обновятся.
  // Dispatch стабилен — потребители DispatchContext обычно не обновятся.
  const memoState = useMemo(() => state, [state]);

  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={memoState}>
        {children}
      </StateContext.Provider>
    </DispatchContext.Provider>
  );
}

export const useAppDispatch = () => useContext(DispatchContext);
export const useAppState = () => useContext(StateContext);

Но даже здесь все потребители StateContext будут обновляться при любом изменении state, потому что контекст не знает, «какая часть состояния нужна компоненту».
Именно эту «селективность» обычно и дают менеджеры состояния через селекторы/подписки.

Пример: что именно даёт Redux (hooks)

Типовой подход: компонент читает только нужный фрагмент состояния через селектор, а обновляется только при изменении результата этого селектора.
Отсюда следует важное правило: селектор должен возвращать стабильные значения (например, примитивы или мемоизированные объекты), иначе можно случайно создавать «ложные изменения».

import React from "react";
import { useSelector, useDispatch } from "react-redux";

export function CartTotal() {
  // Компонент зависит только от cart.total
  const total = useSelector((state) => state.cart.total);
  return <div>Total: {total}</div>;
}

export function ThemeSwitch() {
  // Компонент зависит только от settings.theme
  const theme = useSelector((state) => state.settings.theme);
  const dispatch = useDispatch();

  return (
    <button
      onClick={() =>
        dispatch({
          type: "settings/theme",
          payload: theme === "light" ? "dark" : "light",
        })
      }
    >
      Theme: {theme}
    </button>
  );
}

Если меняется settings.theme, то CartTotal обычно не должен перерисовываться, потому что его зависимость (state.cart.total) не изменилась.
Для контекста с единым объектом такое поведение «по умолчанию» недостижимо без дополнительных приёмов (разбиение на много контекстов, дополнительные селекторные обёртки, внешние хранилища и т.п.).

Пример: что даёт Effector (реактивные единицы)

В effector состояние хранится в store-юнитах, которые обновляются, когда возвращается новое значение (обычно требуется иммутабельный стиль обновлений).
Также поддерживаются производные store (derived), которые вычисляются из других store и обновляются автоматически при изменениях источников.

import { createEvent, createStore, combine } from "effector";

const itemAdded = createEvent();
const themeChanged = createEvent();

const $cart = createStore([])
  .on(itemAdded, (cart, item) => [...cart, item]);

const $theme = createStore("light")
  .on(themeChanged, (_, nextTheme) => nextTheme);

const $cartCount = $cart.map((cart) => cart.length);

// Производное состояние (derived) собирается из других store
const $viewModel = combine({
  theme: $theme,
  cartCount: $cartCount,
});

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

Таблица: React-механизмы vs менеджеры

ПодходГде хранится состояниеКак распространяются обновленияТипичные риски
useState/useReducer локальноВ компонентеПо дереву рендера от родителяДублирование состояния между ветками, сложная синхронизация
Context + useReducerВ провайдереПотребители контекста обновляются при изменении value«Широкие» ререндеры, крупные контексты, пересоздание value
Redux (React-Redux hooks)В storeОбновление по результату селектораОшибки селекторов, возврат новых объектов без мемоизации
EffectorВ store-юнитахРеактивные связи, derived storeСложность освоения модели событий/юнитов на старте

Итого: контекст и локальный стейт позволяют централизовать состояние, но по умолчанию не дают селективных подписок и масштабируемых паттернов, из‑за чего растут перерисовки и сложность сопровождения.
Redux/Effector обычно добавляют более управляемую модель обновлений, точечные реакции на изменения и зрелые практики, а селекторы (или реактивные derived-единицы) помогают обновлять только те части интерфейса, которым действительно нужно обновление.