Контексты и редьюсеры React: Управление состоянием без Redux

mermaid
graph TD
    A[Компонент UI] -->|Дипспатчит действия| B[useReducer]
    B -->|Обновляет состояние| C[State]
    C -->|Передает через контекст| D[Context Provider]
    D -->|Обеспечивает доступ| A
    A -->|Читает контекст| D

Безболезненный стейт-менеджмент в React: Отказ от Redux не означает отказ от структуры

Многие разработчики при упоминании управления состоянием в React автоматически тянутся к Redux или MobX. Но современный React предоставляет инструменты, которые в большинстве случаев делают сторонние решения избыточными. Context API в комбинации с хуками снимает боль пропс-дриллинга, не вводя новых абстракций.

Миф о простоте Context API

Разоблачим главное недоразумение: Context API — не замена глобального состояния общего вида. Его цель — передача данных без явной передачи через промежуточные компоненты. Однако недостаточно просто завернуть приложение в Provider и радоваться жизни.

Проблема производительности проявляется мгновенно. Любое изменение контекста приводит к перерисовке всех компонентов, подписанных на этот контекст, вне зависимости от того, какие их части состояния изменились. "Производительность" и "контекст" могут казаться несовместимыми, но только если вы подходите к задаче без стратегии.

javascript
// Наивная реализация контекста — антипаттерн
const ThemeContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

Почему это проблема? Компонент, использующий useContext(ThemeContext), будет перерисовываться при каждом изменении любого значения в объекте value — даже если пользуется лишь setTheme и не зависит от theme.

Оптимизационный сплит контекста

Первое решение: разделение состояния и методов его обновления. Создадим два контекста — для статики и для экшенов. Это сократит количество ненужных ререндеров.

javascript
const ThemeStateContext = createContext();
const ThemeDispatchContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("dark");
  
  return (
    <ThemeStateContext.Provider value={theme}>
      <ThemeDispatchContext.Provider value={setTheme}>
        {children}
      </ThemeDispatchContext.Provider>
    </ThemeStateContext.Provider>
  );
}

// Использование в компоненте
function ThemedButton() {
  const theme = useContext(ThemeStateContext);
  const setTheme = useContext(ThemeDispatchContext);
  
  const toggleTheme = () => {
    setTheme(prev => prev === "light" ? "dark" : "light");
  };
  
  return (
    <button onClick={toggleTheme} className={theme}>
      Переключить тему
    </button>
  );
}

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

Сложность состояния: Когда хук useReducer меняет правила игры

С ростом сложности логики управления состоянием, useState становится недостаточным. Для синхронных операций с взаимосвязанными состояниями лучшая альтернатива — useReducer.

javascript
// Редьюсер обработки асинхронных операций
function apiReducer(state, action) {
  switch (action.type) {
    case "FETCH_START":
      return { ...state, loading: true, error: null };
    case "FETCH_SUCCESS":
      return { loading: false, data: action.payload, error: null };
    case "FETCH_FAILURE":
      return { ...state, loading: false, error: action.error };
    case "RESET":
      return initialState;
    default:
      throw new Error(`Unhandled action type: ${action.type}`);
  }
}

// Комбинируем с контекстом
const ApiStateContext = createContext();
const ApiDispatchContext = createContext();

function ApiProvider({ children }) {
  const [state, dispatch] = useReducer(apiReducer, {
    data: null,
    loading: false,
    error: null
  });

  return (
    <ApiStateContext.Provider value={state}>
      <ApiDispatchContext.Provider value={dispatch}>
        {children}
      </ApiDispatchContext.Provider>
    </ApiStateContext.Provider>
  );
}

Ключевое отличие: редьюсер гарантирует последовательность изменений состояния и централизацию логики. Выносите побочные эффекты за пределы редьюсера — он должен оставаться чистой функцией.

Вынос бизнес-логики в кастомные хуки

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

javascript
function useUserData() {
  const state = useContext(ApiStateContext);
  const dispatch = useContext(ApiDispatchContext);
  
  const fetchUser = async (userId) => {
    try {
      dispatch({ type: "FETCH_START" });
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      dispatch({ type: "FETCH_SUCCESS", payload: data });
    } catch (error) {
      dispatch({ type: "FETCH_FAILURE", error: error.message });
    }
  };
  
  const reset = () => dispatch({ type: "RESET" });
  
  return {
    user: state.data,
    loading: state.loading,
    error: state.error,
    fetchUser,
    reset
  };
}

// В компоненте
function UserProfile({ id }) {
  const { user, loading, error, fetchUser } = useUserData();
  
  useEffect(() => {
    fetchUser(id);
  }, [id]);
  
  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  
  return <div>{user.name}</div>;
}

Преимущество: бизнес-логика полностью инкапсулирована. Компонент работает с простым интерфейсом, а хуки могут включать мемоизацию, контролируемые рефетчи и сложные цепочки действий.

Выжимка по производительности: Мемоизируем контекст правильно

Распространенная ошибка: объект-значение, который каждый раз создается заново:

javascript
// Плохо: значения Provider будут вызывать ререндеры даже при неизменном состоянии
<AuthContext.Provider value={{ user, login, logout }}>

Решение: мемоизация значений контекста. Для объектов — useMemo, для функций — useCallback.

javascript
function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  
  const login = useCallback((credentials) => {
    // Логика входа
  }, []);
  
  const logout = useCallback(() => {
    // Логика выхода
  }, []);
  
  const value = useMemo(() => ({ user, login, logout }), [user]);
  
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

Функции login и logout остаются стабильными независимо от изменений user. Объект value меняется только когда обновляется сам user.

Для редьюсеров — диспатч функция всегда стабильна, что позволяет безопасно передавать его в контексте без риска лишних ререндеров.

Когда контексту недостаточно: Границы целесообразности

Не используйте Context API как универсальную замену Redux. Ориентируйтесь на эти критерии:

  1. Обновления частоты: Если состояние меняется чаще 10 раз в секунду (например, анимации), Context вызовет фризы

  2. Размер состояния: При штатном состоянии более 10KB могут проявиться задержки

  3. Производные данные: Нужны селекторы? Рассмотрите useMemo + кастомные хуки

  4. DevTools: Отладка изменений состояния через Context сложнее

За пределами useState + useReducer: useSyncExternalStore для интеграций с внешними стейт-менеджерами

React 18 представил хук useSyncExternalStore для безопасной подписки на внешние хранилища. Его можно использовать для создания совместимости с библиотеками наподобие Redux или даже с кастомными решениями.

javascript
function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();
  
  const getState = () => state;
  
  const setState = (newState) => {
    state = typeof newState === "function" ? newState(state) : newState;
    listeners.forEach(listener => listener());
  };
  
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };
  
  return { getState, setState, subscribe };
}

// React-интеграция
const store = createStore({ count: 0 });

function useCustomStore(selector) {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState())
  );
}

Но прежде чем имплементировать подобное, убедитесь что Context + useReducer действительно не покрывает кейс.

Интеграционные рекомендации для больших приложений

  1. Разделяй и властвуй: Два-четыре контекста вместо одного монолита
  2. Ленивые подписчики: Разбейте массивную страницу на независимые области с самостоятельными контекстами
  3. Типизируйте: TypeScript особенно важен для контекста — используйте дженерики и расширенные типы
  4. Профилируйте: React DevTools покажут что именно и когда перерисовывается
typescript
interface UserContextState {
  user: User | null;
  permissions: string[];
}

interface UserContextActions {
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const UserStateContext = createContext<UserContextState | null>(null);
const UserActionsContext = createContext<UserContextActions | null>(null);

Вывод: Архитектура как осознанный выбор

Управление состоянием без Redux — это не упрощенный подход для маленьких приложений. Это самостоятельная методология, требующая дисциплины в зонировании контекстов, умной мемоизации и продуманного дизайна редьюсеров. Для 80% бизнес-приложений Context + useReducer будут покрывать потребности без потери в производительности. Комбинируйте их с кастомными хуками — и получите набор инструментов, многократной расширяемый и масштабируемый.