Урок 21: useReducer (управление сложным состоянием в React)

Мы с вами уже прошли большой путь, изучив основы, компоненты, props и хук useState, который стал нашим верным спутником в управлении состоянием компонентов. Но сегодня мы переходим на новый уровень. Мы будем говорить о состоянии, которое становится слишком сложным для useState.

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

Зачем пользоваться useReducer?

Мы все любим useState за его простоту и прямолинейность. Это идеальный выбор для независимых кусочков состояния: флажков, строк ввода, счетчиков. Но что происходит, когда состояние нашего компонента становится более комплексным?

Допустим, мы создаем компонент для управления профилем пользователя. Сначала это просто имя и email (два useState). Потом мы добавляем загрузку, ошибки, валидацию, возможность редактировать несколько полей одновременно… Наш компонент быстро превращается в месиво из множественных вызовов set-функций, логика обновления состояния размазывается по всему компоненту и становится трудно понять, какое действие приводит к какому изменению состояния. В какой-то момент мы понимаем, что useState больше не помогает, а, наоборот, мешает, делая код хрупким и сложным для отладки.

Именно здесь на сцену выходит useReducer. Этот хук предлагает альтернативный подход к управлению состоянием, который особенно хорошо подходит для двух сценариев:

  1. Когда у вас сложное состояние, состоящее из нескольких взаимосвязанных значений (например, объект с полями {name, email, isLoading, error}).

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

useReducer помогает структурировать и централизовать логику обновления состояния, делая код более предсказуемым и простым для тестирования.

Концепции useReducer: State, Actions и Reducer

Чтобы понять, как работает useReducer, нам нужно усвоить три фундаментальные концепции: состояние (state), действия (actions) и редьюсер (reducer). Давайте представим их как процесс в государственном учреждении (да, звучит скучно, но это очень наглядно!).

  • State (Состояние). Это текущие данные вашего компонента. В нашем бюрократическом примере это текущее досье на гражданина (ваше приложение). В нем записано все: имя, возраст, статус и т.д. Это единственный источник правды.

  • Action (Действие). Это формальное заявление или прошение, которое вы подаете. Вы не можете просто взять и исправить досье. Вы должны подать заявление установленного образца: «Хочу сменить имя», «Хочу обновить статус». В заявлении всегда указано type (тип действия — «CHANGE_NAME») и, опционально, некие payload (полезные данные — новое имя «Максим»).

  • Reducer (Редьюсер). Это чиновник, который принимает ваше заявление. Его работа взять текущее состояние (досье) и действие (заявление) и на основе этого произвести новое состояние (обновленное досье). Редьюсер это чистая функция. Он не меняет исходное досье, а создает совершенно новое, с внесенными изменениями. У него нет побочных эффектов, он только преобразует состояние.

Схема работы всегда одинакова:

  1. В компоненте что-то происходит (например, пользователь нажал кнопку).

  2. Вместо прямого вызова setState мы «диспатчим» (отправляем) действие (action).

  3. Это действие попадает в редьюсер (reducer).

  4. Редьюсер, основываясь на типе действия, вычисляет новое состояние.

  5. Компонент перерисовывается с этим новым состоянием.

Эта однонаправленная поток данных делает поведение компонента крайне предсказуемым.

Синтаксис useReducer

Хук useReducer принимает два обязательных аргумента и возвращает массив из двух элементов.

javascript
const [state, dispatch] = useReducer(reducer, initialState);
  • reducer это функция-редьюсер, которую мы описываем отдельно.

  • initialState начальное состояние, обычно объект.

  • Возвращает он массив, где:

    • state текущее состояние.

    • dispatch это функция, которую мы используем для отправки действий.

Давайте напишем нашу функцию-редьюсер. Ее сигнатура всегда одинакова:

javascript
function reducer(state, action) {
  // Логика обновления состояния на основе action.type
  switch (action.type) {
    case 'ACTION_TYPE_ONE':
      return newState; // Возвращаем НОВЫЙ объект состояния
    case 'ACTION_TYPE_TWO':
      return anotherNewState;
    default:
      // Важно всегда возвращать состояние по умолчанию, если ни один case не подошел
      return state;
  }
}

Ключевой момент. Редьюсер должен быть чистой функцией. Это значит:

  • Не изменяйте напрямую аргументы state и action. Не делайте state.value = action.newValue.

  • Не совершайте сайд-эффектов: никаких API-запросов, таймаутов и т.д. внутри редьюсера.

  • Для любых вычислений используйте только входные аргументы (state и action).

  • Всегда возвращайте новый объект состояния.

Практический пример №1: Умный счетчик

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

1. Определяем начальное состояние:

javascript
const initialState = { count: 0 };

2. Пишем функцию-редьюсер:

javascript
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 };
    case 'SET':
      // Здесь мы используем action.payload, чтобы передать значение
      return { count: action.payload };
    default:
      return state;
  }
}

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

jsx
import React, { useReducer } from 'react';

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

  return (
    <div>
      <h2>Счетчик: {state.count}</h2>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Сбросить</button>
      <button onClick={() => dispatch({ type: 'SET', payload: 100 })}>
        Установить в 100
      </button>
    </div>
  );
}

export default Counter;

Что здесь происходит? При нажатии на кнопку » +1 » мы вызываем dispatch({ type: 'INCREMENT' }). React берет это действие и передает его в нашу counterReducer вместе с текущим состоянием. Редьюсер видит type: 'INCREMENT' и возвращает новый объект состояния { count: state.count + 1 }. Компонент получает это новое состояние и перерисовывается.

Обратите внимание на кнопку «Установить в 100». Здесь мы впервые используем action.payloadpayload это общепринятое имя для свойства, которое несет в себе данные, необходимые для обновления состояния. В данном случае это число 100.

Практический пример №2: Управление формой с множественными полями

Теперь давайте решим задачу, где useReducer проявляет себя во всей красе. Создадим компонент формы для регистрации пользователя.

1. Начальное состояние, это объект с несколькими полями:

javascript
const initialState = {
  username: '',
  email: '',
  password: '',
  isLoading: false,
  error: null,
  isSubmitted: false
};

2. Редьюсер будет обрабатывать разные типы действий:

javascript
function formReducer(state, action) {
  switch (action.type) {
    case 'FIELD_CHANGE':
      // Обрабатываем изменение любого поля ввода
      // action.payload должен содержать { fieldName, value }
      return {
        ...state, // Важно! Сначала создаем копию всего состояния
        [action.payload.fieldName]: action.payload.value,
        error: null // Сбрасываем ошибку при новом вводе
      };
    case 'SUBMIT_START':
      return {
        ...state,
        isLoading: true,
        error: null
      };
    case 'SUBMIT_SUCCESS':
      return {
        ...state,
        isLoading: false,
        isSubmitted: true
      };
    case 'SUBMIT_FAILURE':
      return {
        ...state,
        isLoading: false,
        error: action.payload // action.payload здесь - текст ошибки
      };
    case 'RESET_FORM':
      return initialState;
    default:
      return state;
  }
}

Обратите внимание на кейс 'FIELD_CHANGE'. Вместо того чтобы создавать отдельный обработчик для каждого поля ( onChangeUsernameonChangeEmail), мы создаем один универсальный. Мы используем [ computed property names ] (квадратные скобки), чтобы динамически установить свойство объекта. Это мощный паттерн!

3. Компонент формы:

jsx
import React, { useReducer } from 'react';

function UserRegistrationForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    // Диспатчим действие с типом FIELD_CHANGE и payload {fieldName, value}
    dispatch({
      type: 'FIELD_CHANGE',
      payload: { fieldName: name, value }
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    // Начало отправки
    dispatch({ type: 'SUBMIT_START' });

    // Имитация API-запроса
    try {
      await new Promise((resolve, reject) => {
        setTimeout(() => {
          // С 50% шансом сымитируем ошибку
          if (Math.random() > 0.5) {
            resolve();
          } else {
            reject(new Error('Ошибка сервера: не удалось зарегистрировать пользователя.'));
          }
        }, 1500);
      });

      // Если запрос успешен
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (error) {
      // Если запрос провалился
      dispatch({ type: 'SUBMIT_FAILURE', payload: error.message });
    }
  };

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

  // Если форма отправлена, показываем сообщение
  if (state.isSubmitted) {
    return (
      <div>
        <h2>Поздравляем, {state.username}!</h2>
        <p>Вы успешно зарегистрированы с email {state.email}.</p>
        <button onClick={handleReset}>Зарегистрировать снова</button>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <h2>Регистрация пользователя</h2>
      
      {state.error && <p style={{ color: 'red' }}>{state.error}</p>}
      
      <div>
        <label>
          Имя пользователя:
          <input
            type="text"
            name="username"
            value={state.username}
            onChange={handleInputChange}
            disabled={state.isLoading}
          />
        </label>
      </div>
      
      <div>
        <label>
          Email:
          <input
            type="email"
            name="email"
            value={state.email}
            onChange={handleInputChange}
            disabled={state.isLoading}
          />
        </label>
      </div>
      
      <div>
        <label>
          Пароль:
          <input
            type="password"
            name="password"
            value={state.password}
            onChange={handleInputChange}
            disabled={state.isLoading}
          />
        </label>
      </div>
      
      <button type="submit" disabled={state.isLoading}>
        {state.isLoading ? 'Регистрация...' : 'Зарегистрироваться'}
      </button>
      
      <button type="button" onClick={handleReset} disabled={state.isLoading}>
        Сбросить
      </button>
    </form>
  );
}

export default UserRegistrationForm;

Посмотрите, насколько чистой и структурированной стала логика нашего компонента. Вся бизнес-логика, связанная с обновлением состояния, инкапсулирована в редьюсере. В самом компоненте мы только диспатчим действия в ответ на события. Это разделение ответственности делает код очень читаемым и легким для тестирования. Мы можем протестировать редьюсер отдельно, просто передавая ему состояния и действия и проверяя результат.

Что и когда выбрать, useReducer или useState?

Это самый частый вопрос. Давайте составим простой чек-лист.

Выбирайте useState, когда:

  • Состояние примитивное (число, строка, булево) или простой объект.

  • Обновления состояния простые и независимы друг от друга.

  • Логика обновления не требует глубокой вложенности или зависит от предыдущего состояния лишь в простых случаях.

Выбирайте useReducer, когда:

  • Состояние представляет собой сложный объект или массив с вложенными данными.

  • Следующее состояние сильно зависит от предыдущего (как в нашем счетчике).

  • Логика обновления сложная и требует нескольких шагов (как в форме с загрузкой и ошибками).

  • Вы хотите отделить логику состояния от компонента для улучшения тестируемости и читаемости.

  • В будущем вы планируете использовать useContext для передачи dispatch вглубь дерева компонентов, избегая пропс-дриллинга.

Часто начинают с useState и когда чувствуют, что управление состоянием становится слишком запутанным, рефакторят код на использование useReducer.

Практические задачи для закрепления

Чтобы уверенно освоить useReducer, я предлагаю вам выполнить следующие задачи.

Задача 1: Расширьте счетчик

Модифицируйте наш компонент счетчика. Добавьте кнопку «Удвоить», которая будет умножать текущее значение счетчика на 2. Для этого вам нужно:

  1. Добавить новый case в редьюсер (например, 'MULTIPLY').

  2. В этом кейсе возвращать новое состояние: { count: state.count * 2 }.

  3. Добавить кнопку в JSX, которая диспатчит действие с этим типом.

Задача 2: Список дел (To-Do List)

Это классическая задача для отработки useReducer. Создайте компонент списка дел.

  • Начальное состояние: { todos: [], inputValue: '' }.

  • Действия:

    • 'INPUT_CHANGE' обновляет inputValue.

    • 'ADD_TODO' добавляет новую задачу в массив todos (объект с idtext и completed). Не забудьте очистить inputValue.

    • 'TOGGLE_TODO' меняет флаг completed у задачи по ее id.

    • 'DELETE_TODO' удаляет задачу из массива по id.

  • Рендеринг: Отображайте список задач. Каждая задача должна быть чекбоксом (для переключения статуса) и иметь кнопку для удаления.

Подсказка. При работе с массивами в редьюсере всегда возвращайте новый массив. Используйте методы mapfilter, spread-оператор [...state.todos], но никогда не изменяйте исходный массив методом push или прямым присваиванием по индексу.

Задача 3: useReducer + useContext

Попробуйте комбинировать useReducer и useContext. Создайте контекст (React.createContext), который будет передавать state и dispatch из вашего компонента со списком дел (из Задачи 2) вглубь дерева компонентов. Создайте отдельный компонент TodoItem, который будет получать доступ к dispatch через контекст и использовать его для переключения и удаления задач, не получая эти функции через props.

Итоги урока

Вы только что освоили один из самых важных инструментов React, это хук useReducer. Давайте кратко резюмируем:

  • useReducer это хук для управления сложным, взаимосвязанным состоянием.

  • Он основан на трех китах: State, Actions и Reducer.

  • Редьюсер это чистая функция, которая принимает (state, action) и возвращает новое состояние.

  • Диспетч это функция для отправки действий, которая триггерит обновление состояния.

  • Этот подход делает поток данных предсказуемым, а код хорошо структурированным и легким для тестирования.

  • Выбор между useState и useReducer зависит от сложности состояния и его логики обновления.

Не пугайтесь, если сначала эта концепция покажется немного абстрактной. Как всегда, если остались вопросы, не стесняйтесь перечитывать урок и экспериментировать с кодом. Удачи в изучении.

Хочешь освоить React на практике и стать востребованным фронтенд-разработчиком?

Это был всего лишь один урок из моего полного курса «React для начинающих». В курсе тебя ждет 30 подробных уроков.

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий