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

Для разработчиков React, растущая сложность состояния приложения — неизбежный вызов. Классический useState отлично работает для изолированных компонентов, но при масштабировании легко превращается в запутанный клуб колбэков. Рассмотрим альтернативу, которая не требует установки Redux или MobX, но охватывает 90% потребностей: связка useReducer и Context API.

Когда useState перестает быть другом

Представьте компонент, управляющий корзиной покупок:

jsx
// Наивная реализация
function ShoppingCart() {
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [isLoading, setIsLoading] = useState(false);

  const addItem = (product) => {
    setIsLoading(true);
    fetch('/api/cart', { method: 'POST', body: JSON.stringify(product) })
      .then(() => {
        const updatedItems = [...items, product];
        setItems(updatedItems);
        setTotal(updatedItems.reduce((sum, item) => sum + item.price, 0));
      })
      .finally(() => setIsLoading(false));
  };
  
  // Десятки строк подобной логики...
}

Проблемы:

  1. Разбросанная логика: Обновление items и total выполняется отдельно, рискуя рассинхронизацией.
  2. Повторяемый код: Похожие пайплайны для удаления/обновления товаров.
  3. Сложность тестирования: Бизнес-логика зашита в кмпоненте.

Рецепт: useReducer как state machine

useReducer инкапсулирует логику обновления состояния в предсказуемый конечный автомат. Перепишем пример:

jsx
// Редуктор (reducer) — чистая функция состояния
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price,
      };
    case 'REMOVE_ITEM':
      const filteredItems = state.items.filter(item => item.id !== action.payload.id);
      return {
        ...state,
        items: filteredItems,
        total: filteredItems.reduce((sum, item) => sum + item.price, 0),
      };
    case 'SET_LOADING':
      return { ...state, isLoading: action.payload };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

// Инициализация состояния
const initialState = { 
  items: [], 
  total: 0, 
  isLoading: false 
};

Почему это мощнее?

  • Все изменения состояния описываются в одном месте.
  • Легко добавлять сайд-эффекты (например, логирование).
  • Состояние всегда обновляется атомарно.

Интеграция с Context API: Глобальный доступ без пропс-дриллинга

Создадим контекст и провайдер:

jsx
const CartContext = React.createContext();

function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  // Действия (actions) инкапсулируют асинхронные операции
  const addItem = async (product) => {
    dispatch({ type: 'SET_LOADING', payload: true });
    try {
      await fetch('/api/cart', { method: 'POST', body: JSON.stringify(product) });
      dispatch({ type: 'ADD_ITEM', payload: product });
    } catch (error) {
      console.error('Failed to add item:', error);
    } finally {
      dispatch({ type: 'SET_LOADING', payload: false });
    }
  };

  const value = { 
    state, 
    actions: { addItem } // Экспортируем методы API
  };
  
  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

Теперь любой компонент может использовать корзину без передачи пропсов:

jsx
function ProductList() {
  const { actions } = useContext(CartContext);
  
  return <button onClick={() => actions.addItem(product)}>Add to Cart</button>;
}

function CartSummary() {
  const { state } = useContext(CartContext);
  return <div>Total: {state.total} | Items: {state.items.length}</div>;
}

Критические оптимизации

Избегайте лишних ререндеров

При изменении состояния isLoading перерендериваются все компоненты, использующие useContext(CartContext), даже если их интересует только items. Решение:

jsx
// Вместо: 
const value = { state, actions };

// Разделите контекст на данные и методы:
const CartStateContext = React.createContext();
const CartActionsContext = React.createContext();

function CartProvider({ children }) {
  // ... useReducer логика ...
  return (
    <CartStateContext.Provider value={state}>
      <CartActionsContext.Provider value={actions}>
        {children}
      </CartActionsContext.Provider>
    </CartStateContext.Provider>
  );
}

// Хук для доступа к состоянию 
function useCartState() {
  const context = useContext(CartStateContext);
  if (!context) throw new Error('Used outside CartProvider');
  return context;
}

// Хук для действий 
function useCartActions() {
  const context = useContext(CartActionsContext);
  if (!context) throw new Error('Used outside CartProvider');
  return context;
}

Теперь ProductList использует useCartActions() и не ререндерится при изменении state.items.

Оптимизация тяжелых вычислений

Если вычисление total ресурсоемко, используйте мемоизацию:

jsx
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      const newItems = [...state.items, action.payload];
      return {
        ...state,
        items: newItems,
        total: calculateTotal(newItems), // Вынесенная функция
      };
    // ...
  }
}

// Мемоизация через useMemo внутри компонента тоже работает
function CartSummary() {
  const { items } = useCartState();
  const total = useMemo(() => items.reduce((sum, item) => sum + item.price, 0), [items]);
}

Когда переходить на Redux?

Контекст + useReducer подходит для:

  • Приложений среднего масштаба.
  • Состояния, не требующего persistence или time-travel дебаггинга.
  • Команд, предпочитающих "нативный" подход без зависимостей.

Redux оправдан при:

  • Экстремально большом состоянии (тысячи элементов).
  • Необходимости middleware (например, для обработки сложных асинхронных потоков).
  • Требовании к инструментам разработчика (DevTools).

Паттерны для масштабирования

  1. Модульные редукторы:
    Комбинируйте несколько редукторов через combineReducers (аналог Redux):
jsx
function rootReducer(state, action) {
  return {
    cart: cartReducer(state.cart, action),
    user: userReducer(state.user, action),
  };
}
  1. Сериализуемые действия:
    Всегда передавайте payload как примитивы или простые объекты. Избегайте классов или функций.

  2. Тестирование:
    Редукторы — чистые функции. Тестируйте их изолированно:

jsx
test('ADD_ITEM updates total correctly', () => {
  const state = { items: [], total: 0 };
  const action = { type: 'ADD_ITEM', payload: { id: 1, price: 50 } };
  expect(cartReducer(state, action).total).toBe(50);
});

Заключение

useReducer и Context API — мощный дуэт для управления состоянием, который часто недооценивают. Его ключевые преимущества:

  • Минимальный бойлерплейт по сравнению с Redux.
  • Предсказуемость за счет централизованных обновлений состояния.
  • Гибкость благодаря разделению на actions и state.

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

Философское послесловие: Управление состоянием — это поиск баланса между структурой и гибкостью. Иногда достаточно молотка, не тащите бетономешалку. Лучшая архитектура — та, которая решает ваши задачи сегодня, но не запрещает меняться завтра.