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

Сложность управления состоянием растёт пропорционально масштабу приложения. Когда компонентов становится десятки или сотни, а данные должны передаваться через множество уровней вложенности, пропс-дриллинг превращается в кошмар. Представьте, как меняется state на одном конце приложения и цепной реакцией расходится по дереву компонентов, вынуждая перерисовываться даже те части, которые не зависят от изменённых данных.

Здесь на помощь приходят Context API и хук useReducer — инструменты из стандартной поставки React, которые позволяют создать предсказуемый и масштабируемый способ управления состоянием без установки дополнительных библиотек. Давайте разберёмся, как правильно использовать этот тандем.

Почему бы не использовать useState везде?

Для небольших приложений достаточно встроенного useState. Но когда появляются сложные взаимозависимости компонентов или требования к обработке состояния (валидация, побочные эффекты, асинхронные операции), проблемы проявляются явно:

  1. Пропс-дриллинг: Передача props через 3+ уровня вложенности
  2. Нелокальность изменений: State разбросан по компонентам, логика обновления фрагментирована
  3. Повторный рендеринг: Изменение состояния вызывает рендер всего родительского дерева

Рассмотрим решение:

jsx
// Проблема: слишком глубокий пропс-дриллинг
<App>
  <Header user={user} />
  <Content user={user} updateUser={updateUser} />
  <Sidebar user={user} logout={logout} />
</App>

Context API решает первую проблему, но не даёт структуры для обновлений состояния. Комбинация с useReducer создаёт полноценную state management систему.

Интеграция Context с useReducer

Создадим систему управления состоянием аутентификации:

  1. Определяем структуру состояния и типы действий
typescript
// types.ts
type State = {
  user: null | { id: string; name: string };
  loading: boolean;
  error: string | null;
};

type Action =
  | { type: 'LOGIN_REQUEST' }
  | { type: 'LOGIN_SUCCESS'; payload: { id: string; name: string } }
  | { type: 'LOGIN_ERROR'; payload: string }
  | { type: 'LOGOUT' };
  1. Создаём редьюсер
jsx
// authReducer.ts
export const authReducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'LOGIN_REQUEST':
      return { ...state, loading: true, error: null };
    case 'LOGIN_SUCCESS':
      return { ...state, user: action.payload, loading: false };
    case 'LOGIN_ERROR':
      return { ...state, error: action.payload, loading: false };
    case 'LOGOUT':
      return { ...state, user: null };
    default:
      return state;
  }
};
  1. Инициализируем контекст
jsx
// AuthContext.tsx
import React, { createContext, useContext, useReducer } from 'react';
import { authReducer, State } from './authReducer';

type AuthContextType = {
  state: State;
  dispatch: React.Dispatch<Action>;
};

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

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const AuthProvider: React.FC = ({ children }) => {
  const [state, dispatch] = useReducer(authReducer, initialState);

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

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

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

Основная проблема Context — ре-рендеринг всех потребителей при любом изменении значения. Когда в контексте много данных, это становится критичным. Решение — разделение контекстов и мемоизация:

jsx
// Разделяем контекст данных и действий
const UserContext = createContext<State | null>(null);
const UserActionsContext = createContext<React.Dispatch<Action> | null(
  null
)>;

export const AuthProvider = ({ children }) => {
  const [state, dispatch] = useReducer(authReducer, initialState);
  
  const memoizedState = useMemo(() => state, [state]);
  const memoizedDispatch = useMemo(() => dispatch, [dispatch]);

  return (
    <UserActionsContext.Provider value={memoizedDispatch}>
      <UserContext.Provider value={memoizedState}>
        {children}
      </UserContext.Provider>
    </UserActionsContext.Provider>
  );
};

// Потребляем раздельно
export const useAuthState = () => {
  const context = useContext(UserContext);
  // ... проверки
  return context;
};

export const useAuthDispatch = () => {
  const context = useContext(UserActionsContext);
  // ... проверки
  return context;
};

Компоненты, которым нужны только действия, не будут ререндериться при изменении состояния.

Обработка асинхронных операций

Редьюсеры должны оставаться чистыми функциями без побочных эффектов. Для асинхронных операций (API-запросы) используйте middleware-подход:

jsx
export const login = async (dispatch, credentials) => {
  try {
    dispatch({ type: 'LOGIN_REQUEST' });
    const user = await api.login(credentials);
    dispatch({ type: 'LOGIN_SUCCESS', payload: user });
  } catch (error) {
    dispatch({ type: 'LOGIN_ERROR', payload: error.message });
  }
};

// В компоненте
const LoginForm = () => {
  const dispatch = useAuthDispatch();
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    await login(dispatch, { email, password });
  };

  // ... рендер формы
};

Когда это перестаёт работать?

Pattern Context + useReducer идеально подходит для:

  • Средних по сложности приложений
  • Локализованных участков состояния (авторизация, UI-темы, модалки)
  • Данных с низкой частотой обновлений

Для глобального состояния с высокой частотой обновлений (например, редактор с постоянными изменениями) или для требований сложных middleware (подобных Redux-Saga) рассмотрите библиотечные решения:

  • Zustand: Лёгкая альтернатива Redux с минимальным бойлерплейтом
  • Jotai: Модель атомарного состояния с автоматической оптимизацией
  • Redux Toolkit: Для знакомых с Redux — современная версия с упрощённым API

Распространённые ошибки и их решения

  1. Ненужные ререндеры всех потребителей
jsx
// Ошибка: объединение состояния и действий в один контекст
<AuthContext.Provider value={{ state, dispatch }}>
  {children}
</AuthContext.Provider>

// Решение: разделить контексты или использовать мемоизацию
  1. Сложность тестирования

Тестируйте редьюсеры и хуки изолированно. Используйте React Testing Library для компонентов:

jsx
test('reducer handles login successfully', () => {
  const initialState = { user: null, loading: false, error: null };
  const action = { 
    type: 'LOGIN_SUCCESS', 
    payload: { id: '123', name: 'John' } 
  };
  
  expect(authReducer(initialState, action)).toEqual({
    user: { id: '123', name: 'John' },
    loading: false,
    error: null
  });
});

// Тестируем хук
test('useAuth throws when used outside provider', () => {
  const Component = () => {
    useAuth();
    return null;
  };
  
  expect(() => render(<Component />)).toThrow(
    'useAuth must be used within an AuthProvider'
  );
});
  1. Усложнение логики редьюсера при масштабировании

Разделяйте огромные редьюсеры на более мелкие. Используйте combineReducers:

jsx
const rootReducer = combineReducers({
  auth: authReducer,
  notifications: notificationsReducer,
  settings: settingsReducer
});

Стратегия постепенного внедрения

Не нужно сразу переписывать всё приложение:

  1. Начните с одного модуля (авторизация или темы)
  2. Используйте useContext в компонентах вместо пропсов для конкретной логики
  3. Перемещайте state выше, когда требуется разделение между несколькими компонентами
  4. Заменяйте пересекающиеся useState на useReducer

Для интеграции с классовыми компонентами используйте HOC-обёртку:

jsx
export const withAuth = (Component) => (props) => {
  const auth = useAuth();
  return <Component {...props} auth={auth} />;
};

// Старый классовый компонент теперь имеет доступ к контексту
class Profile extends React.Component {
  // ...
}

export default withAuth(Profile);

Выводы

Комбинация Context API и useReducer даёт достаточные возможности для структурирования состояния даже в крупном React-приложении. Она:

  • Устраняет пропс-дриллинг
  • Централизует логику обновлений
  • Работает без дополнительных зависимостей
  • Позволяет плавно масштабироваться при росте приложения
  • Легко интегрируется с TypeScript для строгой типизации

Помните ключевые правила:

  • Делите состояние на осмысленные области
  • Минимизируйте зоны влияния контекстов
  • Используйте мемоизацию, чтобы избежать ререндеров
  • Тестируйте редьюсеры изолированно

Эта архитектура особенно хороша для команд, где важно поддерживать баланс между производительностью разработки и стабильностью приложения. Только когда вы действительно упираетесь в ограничения платформы — обращайтесь к решениям вроде Zustand или Redux Toolkit. В 80% случаев Context + useReducer будет лучшим выбором.