useReducer — альтернатива useState для сложной логики

16 марта 2026
Автор

Олег Марков

Введение

Когда вы только начинаете работать с React, хук useState кажется универсальным инструментом для управления состоянием. И действительно — для простых случаев он отлично справляется. Однако с ростом сложности компонента вы начинаете замечать, что логика обновления состояния разбросана по множеству обработчиков событий, а сами обработчики становятся всё сложнее.

Именно для таких ситуаций React предоставляет хук useReducer. Он позволяет централизовать логику обновления состояния в одной функции — редьюсере — и управлять сложными переходами между состояниями предсказуемым способом.

В этой статье вы узнаете:

  • Что такое useReducer и как он работает
  • Когда стоит выбирать useReducer вместо useState
  • Как писать редьюсеры и типизировать их в TypeScript
  • Практические примеры: корзина покупок, форма, конечный автомат
  • Как комбинировать useReducer с useContext для глобального состояния
  • Лучшие практики и типичные ошибки

Что такое useReducer

useReducer — это хук React, который позволяет управлять состоянием компонента через паттерн редьюсера. Идея заимствована из Redux и функционального программирования.

Базовый синтаксис

const [state, dispatch] = useReducer(reducer, initialState);

Хук принимает два обязательных аргумента:

  • reducer — чистая функция (state, action) => newState, которая описывает как состояние меняется в ответ на экшены
  • initialState — начальное значение состояния

И возвращает кортеж:

  • state — текущее состояние
  • dispatch — функция для отправки экшенов в редьюсер

Как это работает

Посмотрите на простейший пример — счётчик:

import { useReducer } from 'react';

// Редьюсер: чистая функция (state, action) => newState
function counterReducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      return state; // Важно: всегда возвращать state по умолчанию
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <div>
      <p>Счётчик: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Сбросить</button>
    </div>
  );
}

Когда вы вызываете dispatch({ type: 'increment' }), React:

  1. Вызывает редьюсер с текущим состоянием и переданным экшеном
  2. Сохраняет возвращённое значение как новое состояние
  3. Перерисовывает компонент

Третий аргумент: инициализатор

useReducer принимает необязательный третий аргумент — функцию инициализации:

function init(initialCount) {
  return { count: initialCount };
}

function Counter({ initialCount }) {
  const [state, dispatch] = useReducer(counterReducer, initialCount, init);
  // ...
}

Это полезно когда начальное состояние нужно вычислить из пропса, или когда вы хотите повторно использовать логику инициализации (например, при сбросе состояния через dispatch).

useState vs useReducer: когда что выбирать

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

Используйте useState когда:

  • Состояние простое: одно значение, строка, булево, число
  • Переходы между состояниями простые и независимые
  • Логика обновления умещается в одну строку
// Хорошо подходит для useState
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState('');
const [count, setCount] = useState(0);

Используйте useReducer когда:

  • Состояние — сложный объект с несколькими взаимосвязанными полями
  • Следующее состояние зависит от предыдущего нетривиальным образом
  • Логика обновления состояния сложная и её стоит протестировать отдельно
  • Несколько разных экшенов приводят к похожим изменениям состояния
  • Компонент имеет много обработчиков событий, которые меняют состояние схожим образом
// Хорошо подходит для useReducer
const [formState, dispatch] = useReducer(formReducer, {
  values: { name: '', email: '', password: '' },
  errors: {},
  isSubmitting: false,
  submitError: null,
});

Наглядное сравнение

Рассмотрим управление формой с useState:

// С useState — логика разбросана по компоненту
function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    setError(null);

    try {
      await login(email, password);
    } catch (err) {
      setError(err.message);
      setIsSubmitting(false); // Не забыть сбросить!
    }
  };

  // ...
}

И тот же компонент с useReducer:

// С useReducer — логика централизована в редьюсере
const initialState = {
  email: '',
  password: '',
  isSubmitting: false,
  error: null,
};

function loginReducer(state, action) {
  switch (action.type) {
    case 'field_change':
      return { ...state, [action.field]: action.value };
    case 'submit_start':
      return { ...state, isSubmitting: true, error: null };
    case 'submit_success':
      return { ...state, isSubmitting: false };
    case 'submit_error':
      return { ...state, isSubmitting: false, error: action.error };
    default:
      return state;
  }
}

function LoginForm() {
  const [state, dispatch] = useReducer(loginReducer, initialState);

  const handleSubmit = async (e) => {
    e.preventDefault();
    dispatch({ type: 'submit_start' });

    try {
      await login(state.email, state.password);
      dispatch({ type: 'submit_success' });
    } catch (err) {
      dispatch({ type: 'submit_error', error: err.message });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={state.email}
        onChange={(e) => dispatch({ type: 'field_change', field: 'email', value: e.target.value })}
      />
      <input
        type="password"
        value={state.password}
        onChange={(e) => dispatch({ type: 'field_change', field: 'password', value: e.target.value })}
      />
      {state.error && <p>{state.error}</p>}
      <button disabled={state.isSubmitting}>
        {state.isSubmitting ? 'Входим...' : 'Войти'}
      </button>
    </form>
  );
}

Второй вариант явно описывает все переходы состояния, и редьюсер можно легко протестировать изолированно.

Практические примеры

Пример 1: Корзина покупок

Рассмотрим полноценную корзину покупок — классический пример для useReducer:

import { useReducer } from 'react';

// Типы экшенов — хорошая практика хранить как константы
const CART_ACTIONS = {
  ADD_ITEM: 'ADD_ITEM',
  REMOVE_ITEM: 'REMOVE_ITEM',
  UPDATE_QUANTITY: 'UPDATE_QUANTITY',
  CLEAR_CART: 'CLEAR_CART',
  APPLY_DISCOUNT: 'APPLY_DISCOUNT',
};

// Начальное состояние
const initialCartState = {
  items: [],
  discount: 0,
};

// Редьюсер
function cartReducer(state, action) {
  switch (action.type) {
    case CART_ACTIONS.ADD_ITEM: {
      // Проверяем, есть ли уже такой товар в корзине
      const existingItem = state.items.find(item => item.id === action.product.id);

      if (existingItem) {
        // Увеличиваем количество
        return {
          ...state,
          items: state.items.map(item =>
            item.id === action.product.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          ),
        };
      }

      // Добавляем новый товар
      return {
        ...state,
        items: [...state.items, { ...action.product, quantity: 1 }],
      };
    }

    case CART_ACTIONS.REMOVE_ITEM:
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.id),
      };

    case CART_ACTIONS.UPDATE_QUANTITY: {
      if (action.quantity <= 0) {
        // Если количество 0 или меньше — удаляем товар
        return {
          ...state,
          items: state.items.filter(item => item.id !== action.id),
        };
      }

      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.id
            ? { ...item, quantity: action.quantity }
            : item
        ),
      };
    }

    case CART_ACTIONS.CLEAR_CART:
      return { ...initialCartState };

    case CART_ACTIONS.APPLY_DISCOUNT:
      return {
        ...state,
        discount: action.percent,
      };

    default:
      return state;
  }
}

// Вспомогательные вычисления
function getCartTotals(state) {
  const subtotal = state.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  const discountAmount = subtotal * (state.discount / 100);
  const total = subtotal - discountAmount;
  return { subtotal, discountAmount, total };
}

// Компонент корзины
function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, initialCartState);
  const { subtotal, discountAmount, total } = getCartTotals(cart);

  const handleAddItem = (product) => {
    dispatch({ type: CART_ACTIONS.ADD_ITEM, product });
  };

  const handleRemoveItem = (id) => {
    dispatch({ type: CART_ACTIONS.REMOVE_ITEM, id });
  };

  const handleQuantityChange = (id, quantity) => {
    dispatch({ type: CART_ACTIONS.UPDATE_QUANTITY, id, quantity });
  };

  const handleApplyDiscount = () => {
    dispatch({ type: CART_ACTIONS.APPLY_DISCOUNT, percent: 10 });
  };

  const handleClear = () => {
    dispatch({ type: CART_ACTIONS.CLEAR_CART });
  };

  return (
    <div>
      {/* Список товаров для добавления */}
      <div>
        <button onClick={() => handleAddItem({ id: 1, name: 'Книга', price: 500 })}>
          Добавить книгу
        </button>
        <button onClick={() => handleAddItem({ id: 2, name: 'Курс', price: 2000 })}>
          Добавить курс
        </button>
      </div>

      {/* Корзина */}
      <h2>Корзина ({cart.items.length} позиций)</h2>
      {cart.items.map(item => (
        <div key={item.id}>
          <span>{item.name} — {item.price} ₽</span>
          <input
            type="number"
            value={item.quantity}
            onChange={(e) => handleQuantityChange(item.id, Number(e.target.value))}
            min="0"
          />
          <button onClick={() => handleRemoveItem(item.id)}>Удалить</button>
        </div>
      ))}

      {/* Итоги */}
      <div>
        <p>Подытог: {subtotal} ₽</p>
        {cart.discount > 0 && <p>Скидка ({cart.discount}%): -{discountAmount} ₽</p>}
        <p><strong>Итого: {total} ₽</strong></p>
      </div>

      <button onClick={handleApplyDiscount}>Применить скидку 10%</button>
      <button onClick={handleClear}>Очистить корзину</button>
    </div>
  );
}

Пример 2: Конечный автомат (State Machine)

useReducer отлично подходит для реализации конечных автоматов — когда компонент может находиться в строго определённых состояниях:

import { useReducer } from 'react';

// Возможные состояния
const STATES = {
  IDLE: 'idle',
  LOADING: 'loading',
  SUCCESS: 'success',
  ERROR: 'error',
};

// Начальное состояние
const initialState = {
  status: STATES.IDLE,
  data: null,
  error: null,
};

// Редьюсер-автомат
function fetchReducer(state, action) {
  switch (action.type) {
    case 'fetch_start':
      // Из idle → loading
      if (state.status !== STATES.IDLE && state.status !== STATES.ERROR) {
        return state; // Нельзя начать запрос в другом состоянии
      }
      return { status: STATES.LOADING, data: null, error: null };

    case 'fetch_success':
      // Из loading → success
      if (state.status !== STATES.LOADING) {
        return state;
      }
      return { status: STATES.SUCCESS, data: action.data, error: null };

    case 'fetch_error':
      // Из loading → error
      if (state.status !== STATES.LOADING) {
        return state;
      }
      return { status: STATES.ERROR, data: null, error: action.error };

    case 'reset':
      return initialState;

    default:
      return state;
  }
}

// Хук для работы с API
function useApiRequest(fetchFn) {
  const [state, dispatch] = useReducer(fetchReducer, initialState);

  const execute = async (...args) => {
    dispatch({ type: 'fetch_start' });

    try {
      const data = await fetchFn(...args);
      dispatch({ type: 'fetch_success', data });
    } catch (err) {
      dispatch({ type: 'fetch_error', error: err.message });
    }
  };

  const reset = () => dispatch({ type: 'reset' });

  return { ...state, execute, reset };
}

// Использование
function UserProfile({ userId }) {
  const {
    status,
    data: user,
    error,
    execute: fetchUser,
    reset,
  } = useApiRequest(() => fetch(`/api/users/${userId}`).then(r => r.json()));

  if (status === STATES.IDLE) {
    return <button onClick={fetchUser}>Загрузить профиль</button>;
  }

  if (status === STATES.LOADING) {
    return <div>Загружаем...</div>;
  }

  if (status === STATES.ERROR) {
    return (
      <div>
        <p>Ошибка: {error}</p>
        <button onClick={fetchUser}>Повторить</button>
      </div>
    );
  }

  if (status === STATES.SUCCESS) {
    return (
      <div>
        <h2>{user.name}</h2>
        <p>{user.email}</p>
        <button onClick={reset}>Закрыть</button>
      </div>
    );
  }
}

Пример 3: Многошаговая форма

Управление многошаговой формой — ещё один хороший кандидат для useReducer:

import { useReducer } from 'react';

const STEPS = {
  PERSONAL: 0,
  CONTACT: 1,
  PAYMENT: 2,
  CONFIRMATION: 3,
};

const initialFormState = {
  currentStep: STEPS.PERSONAL,
  data: {
    personal: { firstName: '', lastName: '', birthDate: '' },
    contact: { email: '', phone: '', address: '' },
    payment: { cardNumber: '', cardHolder: '', expiry: '' },
  },
  completedSteps: new Set(),
};

function formReducer(state, action) {
  switch (action.type) {
    case 'update_step_data':
      return {
        ...state,
        data: {
          ...state.data,
          [action.step]: {
            ...state.data[action.step],
            ...action.data,
          },
        },
      };

    case 'complete_step':
      return {
        ...state,
        completedSteps: new Set([...state.completedSteps, action.step]),
        currentStep: state.currentStep + 1,
      };

    case 'go_to_step':
      // Разрешаем переход только к уже пройденным шагам или текущему
      if (action.step <= state.currentStep) {
        return { ...state, currentStep: action.step };
      }
      return state;

    case 'reset':
      return {
        ...initialFormState,
        data: {
          personal: { firstName: '', lastName: '', birthDate: '' },
          contact: { email: '', phone: '', address: '' },
          payment: { cardNumber: '', cardHolder: '', expiry: '' },
        },
        completedSteps: new Set(),
      };

    default:
      return state;
  }
}

function MultiStepForm() {
  const [form, dispatch] = useReducer(formReducer, initialFormState);

  const updateField = (step, field, value) => {
    dispatch({
      type: 'update_step_data',
      step,
      data: { [field]: value },
    });
  };

  const completeStep = (step) => {
    dispatch({ type: 'complete_step', step });
  };

  const stepNames = ['Личные данные', 'Контакты', 'Оплата', 'Подтверждение'];

  return (
    <div>
      {/* Индикатор прогресса */}
      <div style={{ display: 'flex', gap: '8px', marginBottom: '24px' }}>
        {stepNames.map((name, index) => (
          <button
            key={index}
            onClick={() => dispatch({ type: 'go_to_step', step: index })}
            style={{
              background: form.completedSteps.has(index)
                ? 'green'
                : form.currentStep === index
                ? 'blue'
                : 'gray',
              color: 'white',
              padding: '8px 16px',
            }}
          >
            {name}
          </button>
        ))}
      </div>

      {/* Шаг 1: Личные данные */}
      {form.currentStep === STEPS.PERSONAL && (
        <div>
          <h2>Личные данные</h2>
          <input
            placeholder="Имя"
            value={form.data.personal.firstName}
            onChange={(e) => updateField('personal', 'firstName', e.target.value)}
          />
          <input
            placeholder="Фамилия"
            value={form.data.personal.lastName}
            onChange={(e) => updateField('personal', 'lastName', e.target.value)}
          />
          <button onClick={() => completeStep(STEPS.PERSONAL)}>Далее</button>
        </div>
      )}

      {/* Шаг 2: Контакты */}
      {form.currentStep === STEPS.CONTACT && (
        <div>
          <h2>Контактные данные</h2>
          <input
            placeholder="Email"
            value={form.data.contact.email}
            onChange={(e) => updateField('contact', 'email', e.target.value)}
          />
          <input
            placeholder="Телефон"
            value={form.data.contact.phone}
            onChange={(e) => updateField('contact', 'phone', e.target.value)}
          />
          <button onClick={() => completeStep(STEPS.CONTACT)}>Далее</button>
        </div>
      )}

      {/* Шаг 3: Оплата */}
      {form.currentStep === STEPS.PAYMENT && (
        <div>
          <h2>Данные оплаты</h2>
          <input
            placeholder="Номер карты"
            value={form.data.payment.cardNumber}
            onChange={(e) => updateField('payment', 'cardNumber', e.target.value)}
          />
          <button onClick={() => completeStep(STEPS.PAYMENT)}>Далее</button>
        </div>
      )}

      {/* Шаг 4: Подтверждение */}
      {form.currentStep === STEPS.CONFIRMATION && (
        <div>
          <h2>Подтверждение</h2>
          <pre>{JSON.stringify(form.data, null, 2)}</pre>
          <button onClick={() => dispatch({ type: 'reset' })}>
            Заполнить снова
          </button>
        </div>
      )}
    </div>
  );
}

Типизация useReducer в TypeScript

TypeScript отлично работает с useReducer — типы позволяют избежать опечаток в названиях экшенов и неверных структур данных.

Базовая типизация

import { useReducer } from 'react';

// Описываем типы состояния
interface CounterState {
  count: number;
  step: number;
}

// Описываем все возможные экшены через дискриминированный union
type CounterAction =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'reset' }
  | { type: 'set_step'; step: number }
  | { type: 'set_count'; count: number };

// Редьюсер с типами
function counterReducer(state: CounterState, action: CounterAction): CounterState {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + state.step };
    case 'decrement':
      return { ...state, count: state.count - state.step };
    case 'reset':
      return { ...state, count: 0 };
    case 'set_step':
      // TypeScript знает, что здесь action.step существует
      return { ...state, step: action.step };
    case 'set_count':
      return { ...state, count: action.count };
    default:
      return state;
  }
}

const initialState: CounterState = { count: 0, step: 1 };

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, initialState);

  return (
    <div>
      <p>Счётчик: {state.count}</p>
      <label>
        Шаг:
        <input
          type="number"
          value={state.step}
          // TypeScript потребует передать step как number
          onChange={(e) => dispatch({ type: 'set_step', step: Number(e.target.value) })}
        />
      </label>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Сбросить</button>
    </div>
  );
}

Типизация с generic-функцией редьюсера

Для переиспользуемых редьюсеров удобно использовать generic:

// Общий тип для CRUD-операций
type CrudAction<T> =
  | { type: 'create'; item: T }
  | { type: 'update'; id: string; updates: Partial<T> }
  | { type: 'delete'; id: string }
  | { type: 'set_all'; items: T[] };

interface WithId {
  id: string;
}

function createCrudReducer<T extends WithId>() {
  return function reducer(state: T[], action: CrudAction<T>): T[] {
    switch (action.type) {
      case 'create':
        return [...state, action.item];
      case 'update':
        return state.map(item =>
          item.id === action.id ? { ...item, ...action.updates } : item
        );
      case 'delete':
        return state.filter(item => item.id !== action.id);
      case 'set_all':
        return action.items;
      default:
        return state;
    }
  };
}

// Использование
interface Task {
  id: string;
  title: string;
  completed: boolean;
}

const taskReducer = createCrudReducer<Task>();

function TaskList() {
  const [tasks, dispatch] = useReducer(taskReducer, []);

  const addTask = (title: string) => {
    dispatch({
      type: 'create',
      item: { id: Date.now().toString(), title, completed: false },
    });
  };

  const toggleTask = (id: string, completed: boolean) => {
    dispatch({ type: 'update', id, updates: { completed } });
  };

  const deleteTask = (id: string) => {
    dispatch({ type: 'delete', id });
  };

  return (
    <div>
      <button onClick={() => addTask('Новая задача')}>Добавить</button>
      {tasks.map(task => (
        <div key={task.id}>
          <input
            type="checkbox"
            checked={task.completed}
            onChange={(e) => toggleTask(task.id, e.target.checked)}
          />
          <span style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
            {task.title}
          </span>
          <button onClick={() => deleteTask(task.id)}>×</button>
        </div>
      ))}
    </div>
  );
}

useReducer + useContext: глобальное состояние

Комбинирование useReducer с useContext — мощный паттерн для управления глобальным состоянием без Redux.

import { createContext, useContext, useReducer, ReactNode } from 'react';

// Типы
interface User {
  id: string;
  name: string;
  email: string;
}

interface AppState {
  user: User | null;
  isAuthenticated: boolean;
  theme: 'light' | 'dark';
  notifications: string[];
}

type AppAction =
  | { type: 'login'; user: User }
  | { type: 'logout' }
  | { type: 'toggle_theme' }
  | { type: 'add_notification'; message: string }
  | { type: 'remove_notification'; index: number };

// Редьюсер
const initialAppState: AppState = {
  user: null,
  isAuthenticated: false,
  theme: 'light',
  notifications: [],
};

function appReducer(state: AppState, action: AppAction): AppState {
  switch (action.type) {
    case 'login':
      return {
        ...state,
        user: action.user,
        isAuthenticated: true,
      };

    case 'logout':
      return {
        ...state,
        user: null,
        isAuthenticated: false,
      };

    case 'toggle_theme':
      return {
        ...state,
        theme: state.theme === 'light' ? 'dark' : 'light',
      };

    case 'add_notification':
      return {
        ...state,
        notifications: [...state.notifications, action.message],
      };

    case 'remove_notification':
      return {
        ...state,
        notifications: state.notifications.filter((_, i) => i !== action.index),
      };

    default:
      return state;
  }
}

// Контексты
const AppStateContext = createContext<AppState | undefined>(undefined);
const AppDispatchContext = createContext<React.Dispatch<AppAction> | undefined>(undefined);

// Провайдер
function AppProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(appReducer, initialAppState);

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

// Кастомные хуки для удобного использования
function useAppState() {
  const context = useContext(AppStateContext);
  if (!context) {
    throw new Error('useAppState must be used within AppProvider');
  }
  return context;
}

function useAppDispatch() {
  const context = useContext(AppDispatchContext);
  if (!context) {
    throw new Error('useAppDispatch must be used within AppProvider');
  }
  return context;
}

// Использование в компонентах
function Header() {
  const { user, isAuthenticated, theme } = useAppState();
  const dispatch = useAppDispatch();

  return (
    <header>
      {isAuthenticated ? (
        <div>
          <span>Привет, {user?.name}!</span>
          <button onClick={() => dispatch({ type: 'logout' })}>Выйти</button>
        </div>
      ) : (
        <button onClick={() => dispatch({
          type: 'login',
          user: { id: '1', name: 'Иван', email: 'ivan@example.com' }
        })}>
          Войти
        </button>
      )}
      <button onClick={() => dispatch({ type: 'toggle_theme' })}>
        {theme === 'light' ? '🌙' : '☀️'}
      </button>
    </header>
  );
}

// Обёртка всего приложения
function App() {
  return (
    <AppProvider>
      <Header />
      {/* Остальные компоненты */}
    </AppProvider>
  );
}

Разделение dispatch на action creators

Для удобства можно вынести экшены в отдельные функции:

// actions.ts
export const authActions = {
  login: (user: User): AppAction => ({ type: 'login', user }),
  logout: (): AppAction => ({ type: 'logout' }),
};

export const uiActions = {
  toggleTheme: (): AppAction => ({ type: 'toggle_theme' }),
  addNotification: (message: string): AppAction => ({ type: 'add_notification', message }),
  removeNotification: (index: number): AppAction => ({ type: 'remove_notification', index }),
};

// Использование в компоненте
function LoginButton() {
  const dispatch = useAppDispatch();
  const handleLogin = () => {
    dispatch(authActions.login({ id: '1', name: 'Иван', email: 'ivan@example.com' }));
    dispatch(uiActions.addNotification('Вы успешно вошли в систему!'));
  };

  return <button onClick={handleLogin}>Войти</button>;
}

Тестирование редьюсеров

Одно из главных преимуществ useReducer — редьюсер является чистой функцией и легко тестируется без рендеринга компонента:

// cartReducer.test.ts
import { cartReducer, initialCartState, CART_ACTIONS } from './cartReducer';

describe('cartReducer', () => {
  it('добавляет новый товар в пустую корзину', () => {
    const product = { id: 1, name: 'Книга', price: 500 };
    const action = { type: CART_ACTIONS.ADD_ITEM, product };

    const newState = cartReducer(initialCartState, action);

    expect(newState.items).toHaveLength(1);
    expect(newState.items[0]).toMatchObject({ ...product, quantity: 1 });
  });

  it('увеличивает количество при добавлении существующего товара', () => {
    const product = { id: 1, name: 'Книга', price: 500 };
    const stateWithItem = {
      ...initialCartState,
      items: [{ ...product, quantity: 1 }],
    };

    const newState = cartReducer(stateWithItem, {
      type: CART_ACTIONS.ADD_ITEM,
      product,
    });

    expect(newState.items).toHaveLength(1);
    expect(newState.items[0].quantity).toBe(2);
  });

  it('удаляет товар из корзины', () => {
    const stateWithItem = {
      ...initialCartState,
      items: [{ id: 1, name: 'Книга', price: 500, quantity: 1 }],
    };

    const newState = cartReducer(stateWithItem, {
      type: CART_ACTIONS.REMOVE_ITEM,
      id: 1,
    });

    expect(newState.items).toHaveLength(0);
  });

  it('очищает корзину', () => {
    const stateWithItems = {
      ...initialCartState,
      items: [
        { id: 1, name: 'Книга', price: 500, quantity: 2 },
        { id: 2, name: 'Курс', price: 2000, quantity: 1 },
      ],
      discount: 15,
    };

    const newState = cartReducer(stateWithItems, { type: CART_ACTIONS.CLEAR_CART });

    expect(newState.items).toHaveLength(0);
    expect(newState.discount).toBe(0);
  });

  it('применяет скидку', () => {
    const newState = cartReducer(initialCartState, {
      type: CART_ACTIONS.APPLY_DISCOUNT,
      percent: 20,
    });

    expect(newState.discount).toBe(20);
  });

  it('не изменяет состояние при неизвестном экшене', () => {
    const newState = cartReducer(initialCartState, { type: 'UNKNOWN_ACTION' } as any);
    expect(newState).toBe(initialCartState); // Ссылочное равенство!
  });
});

Лучшие практики

1. Редьюсер — это чистая функция

Редьюсер не должен иметь побочных эффектов: никаких запросов к API, никакого изменения аргументов, никаких случайных значений:

// ❌ Плохо — мутация, нечистая функция
function badReducer(state, action) {
  switch (action.type) {
    case 'add_item':
      state.items.push(action.item); // Мутируем аргумент!
      return state; // Возвращаем тот же объект
    default:
      return state;
  }
}

// ✅ Хорошо — иммутабельное обновление
function goodReducer(state, action) {
  switch (action.type) {
    case 'add_item':
      return { ...state, items: [...state.items, action.item] };
    default:
      return state;
  }
}

2. Выносите тяжёлые вычисления в инициализатор

// ❌ Плохо — тяжёлое вычисление при каждом рендере
const [state, dispatch] = useReducer(reducer, computeHeavyInitialState());

// ✅ Хорошо — вычисление только один раз
const [state, dispatch] = useReducer(reducer, null, () => computeHeavyInitialState());

3. Используйте константы для типов экшенов

// ❌ Плохо — строки напрямую (легко опечататься)
dispatch({ type: 'incrment' }); // Опечатка не вызовет ошибку TypeScript без типизации

// ✅ Хорошо — константы или TypeScript типы
const ACTIONS = {
  INCREMENT: 'increment',
  DECREMENT: 'decrement',
} as const;

dispatch({ type: ACTIONS.INCREMENT }); // TypeScript поймает опечатку

4. Храните редьюсер вне компонента

// ❌ Плохо — редьюсер пересоздаётся при каждом рендере
function MyComponent() {
  function reducer(state, action) { /* ... */ }
  const [state, dispatch] = useReducer(reducer, initialState);
}

// ✅ Хорошо — редьюсер определён вне компонента
function reducer(state, action) { /* ... */ }

function MyComponent() {
  const [state, dispatch] = useReducer(reducer, initialState);
}

5. Не дублируйте состояние — вычисляйте производные данные

// ❌ Плохо — дублирование в состоянии
const [state, dispatch] = useReducer(reducer, {
  items: [],
  totalPrice: 0, // Вычисляется из items — не нужно хранить
  itemCount: 0,  // Вычисляется из items — не нужно хранить
});

// ✅ Хорошо — вычисляем при рендере
const [state, dispatch] = useReducer(reducer, { items: [] });
const totalPrice = state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const itemCount = state.items.length;

6. Группируйте связанные экшены в один

// ❌ Плохо — несколько dispatch для одной операции
const handleSubmit = async () => {
  dispatch({ type: 'SET_LOADING', loading: true });
  dispatch({ type: 'CLEAR_ERROR' });
  // ... fetch
  dispatch({ type: 'SET_DATA', data });
  dispatch({ type: 'SET_LOADING', loading: false });
};

// ✅ Хорошо — атомарные переходы
const handleSubmit = async () => {
  dispatch({ type: 'submit_start' });
  try {
    const data = await fetchData();
    dispatch({ type: 'submit_success', data });
  } catch (err) {
    dispatch({ type: 'submit_error', error: err.message });
  }
};

Типичные ошибки

Ошибка 1: Мутация состояния

// ❌ Неправильно
function reducer(state, action) {
  switch (action.type) {
    case 'toggle_item':
      // Прямая мутация — React не увидит изменения!
      const item = state.items.find(i => i.id === action.id);
      item.checked = !item.checked;
      return state;
  }
}

// ✅ Правильно
function reducer(state, action) {
  switch (action.type) {
    case 'toggle_item':
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.id ? { ...item, checked: !item.checked } : item
        ),
      };
  }
}

Ошибка 2: Забытый default

// ❌ Опасно — без default редьюсер может вернуть undefined
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    // Нет default!
  }
}

// ✅ Правильно
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    default:
      return state; // Всегда возвращаем состояние
  }
}

Ошибка 3: Побочные эффекты в редьюсере

// ❌ Неправильно — побочные эффекты в редьюсере
function reducer(state, action) {
  switch (action.type) {
    case 'add_item':
      localStorage.setItem('cart', JSON.stringify([...state.items, action.item])); // Побочный эффект!
      return { ...state, items: [...state.items, action.item] };
  }
}

// ✅ Правильно — побочные эффекты в useEffect
function reducer(state, action) {
  switch (action.type) {
    case 'add_item':
      return { ...state, items: [...state.items, action.item] };
    default:
      return state;
  }
}

function Cart() {
  const [state, dispatch] = useReducer(reducer, initialState);

  // Синхронизация с localStorage через useEffect
  useEffect(() => {
    localStorage.setItem('cart', JSON.stringify(state.items));
  }, [state.items]);
}

Ошибка 4: Слишком широкий контекст с dispatch

// ❌ Плохо — передаём dispatch через props или один большой контекст
// Это приведёт к лишним перерисовкам

// ✅ Хорошо — разделяем контекст состояния и dispatch
const StateContext = createContext(null);
const DispatchContext = createContext(null);
// dispatch никогда не меняется между рендерами,
// поэтому компоненты, использующие только dispatch, не будут перерисовываться

useReducer vs Redux

useReducer в паре с useContext решает много задач, которые раньше требовали Redux. Но у каждого инструмента есть своя ниша:

Критерий useReducer + useContext Redux Toolkit
Сложность настройки Минимальная Средняя
DevTools Нет (без плагинов) Redux DevTools
Middleware Вручную Встроенная поддержка
Производительность Базовая Оптимизированная
Масштабируемость Средняя Высокая
Размер бандла 0 (встроено в React) ~14KB gzipped
Подходит для Средние приложения Крупные приложения

Выбирайте useReducer + useContext для средних проектов, и переходите на Redux Toolkit (или Zustand, Jotai) когда:

  • Нужны Redux DevTools для отладки
  • Приложение достаточно большое и появляются проблемы производительности
  • Нужна сложная middleware-логика (логирование, кеширование, оптимистичные обновления)

Часто задаваемые вопросы

Q: Можно ли использовать несколько useReducer в одном компоненте?

Да, это вполне нормально. Разделяйте состояние по смысловым доменам:

function Dashboard() {
  const [userState, userDispatch] = useReducer(userReducer, initialUserState);
  const [filtersState, filtersDispatch] = useReducer(filtersReducer, initialFiltersState);
  const [uiState, uiDispatch] = useReducer(uiReducer, initialUiState);
}

Q: Почему React вызывает редьюсер дважды в режиме разработки?

В Strict Mode React специально вызывает редьюсер дважды, чтобы убедиться, что он является чистой функцией. Это нормальное поведение только в development-окружении.

Q: Как использовать useReducer с асинхронными операциями?

Редьюсер синхронный, но вы можете выполнять async-код в обработчиках событий до/после dispatch:

function MyComponent() {
  const [state, dispatch] = useReducer(reducer, initialState);

  const handleFetch = async () => {
    dispatch({ type: 'fetch_start' });
    try {
      const data = await api.getData();
      dispatch({ type: 'fetch_success', data });
    } catch (error) {
      dispatch({ type: 'fetch_error', error: error.message });
    }
  };
}

Q: Когда стоит мемоизировать dispatch?

Никогда — функция dispatch, возвращаемая useReducer, стабильна между рендерами (как и setState из useState). Можно безопасно передавать её в дочерние компоненты и в массив зависимостей useEffect.

Q: Как «сбросить» состояние к начальному?

Используйте инициализатор:

function init(initialState) {
  return initialState;
}

function reducer(state, action) {
  switch (action.type) {
    case 'reset':
      return init(action.initialState);
    // ...
  }
}

function MyComponent({ initialData }) {
  const [state, dispatch] = useReducer(reducer, initialData, init);
  const handleReset = () => dispatch({ type: 'reset', initialState: initialData });
}

Заключение

useReducer — мощный инструмент для управления сложным состоянием в React-компонентах. Он особенно полезен когда:

  • Состояние имеет сложную структуру с несколькими взаимосвязанными полями
  • Существует много вариантов изменения состояния
  • Нужна чёткая документация того, как состояние может меняться
  • Вы хотите легко тестируемую логику состояния

Ключевые принципы:

  1. Редьюсер — чистая функция: нет побочных эффектов, нет мутаций
  2. Экшены описывают что произошло, а не как изменить состояние
  3. Иммутабельность: всегда создавайте новые объекты
  4. Всегда возвращайте state в default: для неизвестных экшенов
  5. Вычисляйте производные данные, а не храните их в состоянии

Комбинируя useReducer с useContext, вы получаете мощную систему управления состоянием без внешних зависимостей — отличный выбор для малых и средних React-приложений.

Стрелочка влевоuseCallback в React — мемоизация функцийuseMemo в React: как и когда оптимизировать тяжелые вычисленияСтрелочка вправо

Постройте личный план изучения React до уровня Middle — бесплатно!

React — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Бесплатные лекции

Все гайды по React

Uncontrolled Components: когда DOM управляет даннымиRender Props: гибкое управление рендерингом в ReactПрофилирование React: как найти и устранить узкие местаЧастичное применение: как создавать компоненты без лишнего кодаЛенивая загрузка: как ускорить React-приложение в разыHOC в React: мастерство композиции компонентовuseMemo: как спасти производительность от тяжелых вычисленийError Boundaries: создаем надежные React-приложенияКонтролируемые компоненты в React: полный контроль над формамиCompound Components в React: создаем гибкие компоненты с мощным APIКомпозиция компонентов в React: строим гибкие интерфейсыCode Splitting в React: как уменьшить бандл и ускорить загрузку приложенияАсинхронные компоненты в React: новый стандарт работы с данными
useState в React что это и как использоватьuseTransition - плавные переходы между состояниямиuseSyncExternalStore — работа с внешними сторамиuseRef в React — создание ссылок на DOM и значенияuseInsertionEffect — внедрение стилей до мутаций DOMuseLayoutEffect в React — эффект до отрисовкиuseImperativeHandle в React — настройка ref дочернего компонентаuseId — генерация уникальных идентификаторовuseDeferredValue — отложенное обновление состоянияuseDebugValue — отладка кастомных хуковuseCallback в React — мемоизация функцийuseReducer — альтернатива useState для сложной логикиuseMemo в React: как и когда оптимизировать тяжелые вычисленияuseEffect в React что это и как использоватьuseContext — работа с контекстом в ReactОптимизация рендеринга в React: от теории к глубокой практикеЧто такое useRef и как его применять в ReactКак и зачем использовать React HooksУправление состоянием в React через ContextКак предотвратить лишние ре-рендеры в React: полное руководствоuseMemo vs useCallback: подробное руководство по мемоизации в ReactПравила хуков — правила использованияuseEffect vs useLayoutEffect: в чём разница и какой хук выбрать?Кастомные хуки в React — создание собственных хуковuseState продвинутое использование в React
StrictMode в React — как находить ошибки на этапе разработкиСерверные компоненты React (RSC) — подробный разбор и практикаКак работает рендеринг в ReactЧто такое props в React и как их правильно использоватьКак работает JSX связка React и HTMLЧто такое React.js и как его использоватьКак использовать элементы в ReactКак использовать React DOM в проектеЧто такое компоненты в React и как их применятьРабота с children в ReactПорталы в React: рендер компонентов вне иерархии DOMFragment в React: группировка элементов без лишних узлов DOM
Открыть базу знаний

Лучшие курсы по теме

изображение курса

React и Redux Toolkit

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий