Управление состоянием в React: Глубже Context API и useReducer

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

Проблема: когда состояние выходит из-под контроля

Представьте:

jsx
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [notifications, setNotifications] = useState([]);

  return (
    <Header user={user} theme={theme} setTheme={setTheme} />
    <Dashboard 
      user={user} 
      theme={theme} 
      notifications={notifications}
      setNotifications={setNotifications}
    />
    <Sidebar
      user={user}
      theme={theme}
      setTheme={setTheme}
    />
  );
}

Каждый новый элемент состояния раздувает интерфейсы компонентов. Изменение структуры состояния требует правки десятков компонентов. Производительность страдает от лишних ререндеров — классический пропс-дриллинг работает по принципу "затронул один — обнови всех".

Решение на борту: Context + Reducer

1. Создаем контекст по правилам

Контекст должен:

  • Инкапсулировать логику предметной области
  • Минимизировать повторные рендеры
  • Обеспечивать предсказуемую структуру

Для примера возьмем аутентификацию:

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

const AuthContext = createContext();

const initialState = {
  user: null,
  loading: false,
  error: null,
  isAuthenticated: false
};

function reducer(state, action) {
  switch (action.type) {
    case 'LOGIN_REQUEST':
      return { ...state, loading: true };
    case 'LOGIN_SUCCESS':
      return { 
        ...state, 
        user: action.payload, 
        loading: false,
        isAuthenticated: true
      };
    case 'LOGIN_FAILURE':
      return { ...state, error: action.error, loading: false };
    case 'LOGOUT':
      return { ...initialState };
    case 'EDIT_PROFILE':
      return { ...state, user: { ...state.user, ...action.payload } };
    default:
      throw new Error(`Unhanded action type: ${action.type}`);
  }
}

2. Оптимизируем провайдер

Ключевой провайдер должен:

  • Предотвращать лишние ререндеры
  • Предоставлять стабильные ссылки на функции
  • Обрабатывать побочные эффекты
javascript
export function AuthProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  
  const actions = useMemo(() => ({
    login: async (credentials) => {
      dispatch({ type: 'LOGIN_REQUEST' });
      try {
        const user = await api.login(credentials);
        dispatch({ type: 'LOGIN_SUCCESS', payload: user });
      } catch (error) {
        dispatch({ type: 'LOGIN_FAILURE', error });
        throw error; // Пробрасываем дальше для обработки в UI
      }
    },
    logout: () => {
      dispatch({ type: 'LOGOUT' });
      api.cleanupSession();
    },
    updateProfile: (data) => {
      dispatch({ type: 'EDIT_PROFILE', payload: data });
    }
  }), []);

  const value = useMemo(() => ({ 
    ...state, 
    actions 
  }), [state, actions]);

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

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
};

Обратите внимание:

  • useMemo стабилизирует объект действий
  • Состояние и действия разделены
  • Ошибки пробрасываются на уровень UI

3. Используем в компонентах

jsx
function LoginForm() {
  const { loading, error, actions } = useAuth();
  const [formData, setFormData] = useState({ email: '', password: '' });

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      await actions.login(formData);
      // Перенаправление после успеха
    } catch {
      // Ошибка уже обновлена в контексте
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <ErrorBanner message={error.message} />}
      <input 
        type="email" 
        value={formData.email} 
        onChange={(e) => setFormData({...formData, email: e.target.value})} 
      />
      <input 
        type="password" 
        value={formData.password} 
        onChange={/* аналогично */} 
      />
      <button disabled={loading}>
        {loading ? 'Вход...' : 'Войти'}
      </button>
    </form>
  );
}

4. Решаем главную проблему: ререндеры

Почему после логина компонент Navbar (который использует только имя пользователя) самопроизвольно рендерится при изменении счётчика уведомлений? Потому что они в одном контексте!

Решение — разбить контексты по областям:

javascript
// Оптимизированная структура контекстов
export function AppProviders({ children }) {
  return (
    <AuthProvider>
      <NotificationsProvider>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </NotificationsProvider>
    </AuthProvider>
  );
}

Каждый провайдер отвечает за свою доменную область. Разделение снижает частоту ререндеров и улучшает производительность на 20-40% в среднестатистическом приложении.

5. Тесты: гарантии стабильности

Хуки контекста прекрасно тестируются с помощью Jest и React Testing Library:

javascript
test('login updates auth state', async () => {
  const TestComponent = () => {
    const { user, isAuthenticated } = useAuth();
    return (
      <div>
        <span data-testid="user">{user?.name}</span>
        <span data-testid="status">{isAuthenticated.toString()}</span>
      </div>
    );
  };

  render(
    <AuthProvider>
      <TestComponent />
    </AuthProvider>
  );

  // Начальное состояние
  expect(screen.getByTestId('status').textContent).toBe('false');

  // Выполняем действие
  const { actions } = renderHook(() => useAuth()).result.current;
  await actions.login({ email: 'test@test.com', password: 'secure' });

  // Проверяем изменения
  expect(screen.getByTestId('user').textContent).toBe('Alex');
  expect(screen.getByTestId('status').textContent).toBe('true');
});

Когда Context API недостаточно? Сигналы тревоги

Рассмотрите Redux Toolkit если:

  • Вы оперируете сложными взаимосвязями между состоянием (EntityAdapter)
  • Нужна визуализация состояния при дебаггинге (Redux DevTools)
  • Пакетная обработка обновлений для критичных по производительности участков
  • Интеграция с middleware (сетевые запросы, кеширование)
  • Потребность в шаблонных способах обновления состояния через полный набор плагинов
  • Нужны кеширующие решения для запросов — RTK Query проявит себя лучше, чем кастомные велосипеды.

Для 90% приложений Context API с грамотной архитектурой прекрасно заменяет Redux. В остальные 10% входят высокочастотные UI (диаграммы, графики), серьёзные бизнес-системы с сложными валидациями и крупные проекты с большой командой разработчиков.

Пилуйте дерево компонентов и состояние отдельно

  1. Дифференцируйте контексты по частоте изменений — авторизация, нотификации и темы существуют в разных временных измерениях.
  2. Инжектируйте действия через контекст, а не импортируйте напрямую — упрощает тестирование и SSR.
  3. Трансформации данных держите в редьюсерах — компоненты не должны включать логику расчётов изменений.
  4. Стабилизируйте ссылки тщательноuseMemo, useCallback для объектов контекста.
  5. Типизировать обязательно — TypeScript interface для контекста устранит большинство ошибок сопоставления.

Для проверки гипотезы эффективности контекста включите в DevTools подсчёт ререндеров при профилировании. Оптимальная работа достигается при частоте обновлений не более чем на глубину трёх зависимостей.

Итого: разгружайте компоненты, а не философию

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

Пример из практики: приложение с 500 000 строк кода, где 70% состояния управлялось через Context API (40+ контекстов), потребовало внедрения Redux на 25% при изменении спецификации в сторону внедрения реактивных обновлений. Масштабируйте с долгосрочной перспективой.