React State Management: Предсказуемость с Context API и useReducer без Redux

Большинство React-разработчиков начинают с useState для управления состоянием компонентов. Это работает отлично, пока мы имеем дело с локальными, изолированными данными. Но рано или поздно возникает необходимость разделять состояние между далекими компонентами. Первой реакцией часто становится "пропс-дриллинг" – передача данных через множество промежуточных компонентов. Это не просто утомительно; это создает хрупкие связи и усложняет рефакторинг.

Context API появился как встроенное решение для передачи данных без явной передачи пропсов. Но его часто критикуют за недостатки в производительности и предсказуемости обновлений. Тем не менее, в сочетании с useReducer он образует мощную пару, способную удовлетворить потребности многих приложений без привлечения тяжеловесных библиотек.

Почему Context + useReducer, а не только Context с useState?

Подход с прокидыванием useState через контекст кажется простым:

javascript
const UserContext = createContext();

function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

Проблемы:

  1. Избыточные ререндеры: Любой компонент, подписанный на контекст, ререндерится при любом изменении user или setUser (даже если он использует только setUser).
  2. Отсутствие структуры: Свобода прямого вызова setUser из любого места затрудняет отслеживание изменений состояния, особенно в сложных цепочках.
  3. Проблемы с зависимостями: При использовании одного контекста для разных типов данных ререндеры становятся неизбирательными.

useReducer решает эти проблемы, вводя дисциплину:

  • Состояние обновляется через предсказуемые действия (actions) с четкой сигнатурой
  • Логика изменений инкапсулирована в редьюсере
  • Возможность сложных преобразований состояния в одном месте

Паттерн: Контекст + Редуктор

Начнем с создания хранилища (store):

javascript
// ThemeContext.js
import React, { createContext, useContext, useReducer, useMemo } from 'react';

const initialState = {
  theme: 'light',
  systemTheme: 'dark',
  userOverride: null,
};

function themeReducer(state, action) {
  switch (action.type) {
    case 'SET_THEME':
      return { ...state, userOverride: action.payload };
    case 'SYNC_SYSTEM_THEME':
      return { 
        ...state, 
        systemTheme: action.payload,
        theme: state.userOverride || action.payload 
      };
    case 'RESET_OVERRIDE':
      return { ...state, userOverride: null, theme: state.systemTheme };
    default:
      return state;
  }
}

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

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

  // Мемоизация контекста для оптимизации ререндеров
  const stateContextValue = useMemo(() => state, [state]);
  const dispatchContextValue = useMemo(() => dispatch, [dispatch]);

  return (
    <ThemeStateContext.Provider value={stateContextValue}>
      <ThemeDispatchContext.Provider value={dispatchContextValue}>
        {children}
      </ThemeDispatchContext.Provider>
    </ThemeStateContext.Provider>
  );
}

// Хуки для доступа
export function useThemeState() {
  const context = useContext(ThemeStateContext);
  if (context === null) throw new Error('Missing ThemeProvider');
  return context;
}

export function useThemeDispatch() {
  const context = useContext(ThemeDispatchContext);
  if (context === null) throw new Error('Missing ThemeProvider');
  return context;
}

Ключевые решения и нюансы:

  1. Разделение контекстов: Отдельный контекст для состояния (ThemeStateContext) и диспетчера (ThemeDispatchContext) обеспечивает точечные ререндеры. Компонент, использующий только диспетчер, никогда не ререндерится при изменении состояния.

  2. Мемоизация объектов контекста: Использование useMemo для значений контекста предотвращает создание новых ссылок на объекты при каждом рендере ThemeProvider, что устраняет ненужные ререндеры потребителей.

  3. Строго типизированные действия: Каждое действие (action) имеет тип (type) и понятные полезные данные (payload). Это делает систему предсказуемой:

javascript
// Пример использования в компоненте
function ThemeToggle() {
  const dispatch = useThemeDispatch();
  
  const handleToggle = () => {
    dispatch({ type: 'SET_THEME', payload: 'dark' });
  };
  
  return <button onClick={handleToggle}>Dark Mode</button>;
}
  1. Упрощение бизнес-логики в компонентах: Компоненты не содержат сложной логики обновления — только отправка действий:
javascript
function SystemThemeListener() {
  const dispatch = useThemeDispatch();
  
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e) => {
      dispatch({ type: 'SYNC_SYSTEM_THEME', payload: e.matches ? 'dark' : 'light' });
    };
    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, [dispatch]);
  
  return null;
}

Оптимизация производительности:

Даже с раздельными контекстами потребители состояния будут ререндериться при изменении любой части объекта состояния. Для компонентов, зависящих только от части состояния — используйте селекторы:

javascript
function useTheme() {
  const state = useThemeState();
  return useMemo(() => state.theme, [state.theme]); // Результат мемоизируется
}

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

Когда этого достаточно (а когда — нет)

Контекст + useReducer эффективен для:

  • Глобальных настроек (тема, язык)
  • Профиля пользователя
  • Некритичных к производительности данных в малопроизводительных приложениях

Рассмотрите Redux, Zustand или MobX если:

  • Частые, объемные обновления состояния (панели данных)
  • Сложная асинхронная логика с thunks/sagas
  • Жесткие требования к производительности особенно в интенсивных интерфейсах
  • Требование инструментов разработчика (DevTools) для отладки

Остерегайтесь типичных ошибок:

  1. Единый монолитный контекст для всего приложения: Это антипаттерн. Дробите контексты по логическим доменам (пользователь, настройки, данные) чтобы ограничить распространение ререндеров.

  2. Изменение состояния внутри контекста вместо отправки действий: Нарушает поток однонаправленности данных и усложняет отслеживание изменений. Всегда используйте dispatch.

  3. Игнорирование мемоизации: Отсутствие useMemo для значений контекста гарантированно вызовет проблемы с ререндерами в больших проектах.

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