Централизованное состояние: 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/эффекты/события, централизованное место для побочных эффектов и единый подход к обработке ошибок и отмен.
Инструменты отладки и дисциплина
На практике сильный выигрыш даёт не «красивость», а наблюдаемость: понятные причины изменения состояния, воспроизводимость, удобство анализа цепочки событий. Для командной разработки это часто превращается в снижение стоимости изменений и ревью.
Как сделать централизованное состояние на 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-единицы) помогают обновлять только те части интерфейса, которым действительно нужно обновление.