Kodokon kodokon.com

useReducer для сложного состояния

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

9 мин · 3 вопросов

Открыть этот урок в Kodokon

Как только компонент управляет несколькими значениями состояния, которые меняются вместе - статусом запроса, данными, ошибкой - множество вызовов useState разбрасывает логику переходов по всем обработчикам событий. Симптом тебе знаком: три сеттера, которые надо вызвать в правильном порядке, и рано или поздно несогласованное состояние, например статус success рядом с ненулевой ошибкой. useReducer переворачивает ответственность: состояние становится единым объектом, а каждое изменение проходит через действие, которое интерпретирует одна центральная функция - редьюсер.

JAVASCRIPT
function requestReducer(state, action) {
  switch (action.type) {
    case "start":
      return { status: "loading", data: null, error: null };
    case "resolve":
      return {
        status: "success",
        data: action.data,
        error: null,
      };
    case "reject":
      return {
        status: "error",
        data: null,
        error: action.error,
      };
    default:
      throw new Error("Unknown action: " + action.type);
  }
}
Каждый переход заново собирает полное и согласованное состояние.

Редьюсер - чистая функция: он получает текущее состояние и действие, а затем возвращает следующее состояние, без побочных эффектов. Выгода видна сразу: переходы становятся явными и исчерпывающими, невозможные состояния исчезают в корне (каждый case заново собирает согласованный объект), а логика читается в одном месте, а не разбросана по коду. Полезный тревожный сигнал: если ты копируешь одну и ту же последовательность вызовов setState в двух местах, тебе, скорее всего, не хватает редьюсера.

JSX
function UserProfile() {
  const [state, dispatch] = useReducer(requestReducer, {
    status: "idle",
    data: null,
    error: null,
  });

  async function load(id) {
    dispatch({ type: "start" });
    try {
      const res = await fetch("/api/users/" + id);
      const data = await res.json();
      dispatch({ type: "resolve", data });
    } catch (error) {
      dispatch({ type: "reject", error });
    }
  }

  return <button onClick={() => load(1)}>Load</button>;
}
Компонент выражает намерение, редьюсер решает, каким будет состояние.

Выбор между useState и useReducer - не вопрос вкуса. Оставь useState для простых независимых значений; переходи на useReducer, когда следующее состояние зависит от предыдущего или когда несколько полей меняются вместе. Два дополнительных плюса: третий аргумент даёт ленивую инициализацию (useReducer(reducer, arg, init) запускает init только при первом рендере), а React гарантирует, что dispatch сохраняет одну и ту же идентичность от рендера к рендеру - его можно передавать детям без useCallback.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Какой признак должен направить тебя к useReducer, а не к нескольким вызовам useState?
    • Компонент перевалил за сотню строк
    • Несколько значений состояния меняются вместе, и новое состояние зависит от предыдущего
    • Компонент получает больше трёх пропсов
    • В состоянии есть массив
  2. Редьюсер возвращает тот же объект состояния, который получил, мутировав его на месте. Что сделает React?
    • Сравнит старую и новую ссылки, найдёт их одинаковыми и пропустит перерисовку
    • Обнаружит глубокую мутацию и перерисует компонент
    • Выбросит ошибку, потому что в разработке состояние заморожено
  3. Почему dispatch можно передать мемоизированному ребёнку без всяких предосторожностей?
    • Потому что React.memo игнорирует пропсы-функции
    • Потому что dispatch создаётся заново, но структурно одинаков при каждом рендере
    • Потому что React гарантирует, что dispatch сохраняет одну и ту же идентичность на всё время жизни компонента