Урок 9: Асинхронные операции с `createAsyncThunk` в Redux Toolkit

Мы продолжаем изучение управления состоянием с Redux и Redux Toolkit. До этого мы работали с синхронными операциями. Теми, которые выполняются мгновенно, «Лайк» увеличение счетчика или добавление задачи в список. Но современные веб-приложения живут данными, которые приходят с серверов. Это асинхронные операции, запросы к API, работа с базой данных, загрузка файлов. Они не происходят мгновенно и их результат неизвестен заранее. Он может быть успешным, а может и провалиться. Сегодня мы научимся надежно работать с такими операциями в Redux Toolkit с помощью мощной функции createAsyncThunk.

Проблема асинхронности в Redux

Давай на секунду представим, как бы мы пытались сделать запрос к API в «ванильном» Redux, без специальных инструментов. Допустим, нам нужно загрузить список пользователей.

  • Диспатч экшена начала загрузки: { type: 'users/fetchUsers/pending' }. Это нужно, чтобы наш интерфейс отреагировал, показал, например спиннер загрузки.

  • Выполнение самого запроса: Мы бы использовали fetch или axios внутри действия (action creator), которое не является чистой функцией.

  • Обработка результата:

    1. Если запрос успешен, мы диспатчим экшен успеха: { type: 'users/fetchUsers/fulfilled', payload: data }.

    2. Если произошла ошибка, мы диспатчим экшен ошибки: { type: 'users/fetchUsers/rejected', error: 'Something went wrong' }.

Проблема в том, что «голый» Redux ожидает, что экшены это простые объекты, а редьюсеры чистые функции. Запрос к API это side effect (побочный эффект), который не является ни чистым, ни синхронным. Раньше для решения этой проблемы приходилось использовать промежуточное ПО (middleware), самое популярное из которых, это redux-thunk. Thunk позволяет диспатчить не только объекты, но и функции, которые могут содержать side effects и сами диспатчить экшены.

Redux Toolkit включает redux-thunk «из коробки» и предоставляет нам абстракцию более высокого уровня createAsyncThunk. Эта функция автоматически генерирует три типа экшенов (pending, fulfilled, rejected) и берет на всю грязную работу по их диспатчу. Нам остается только описать сам запрос и то, как состояние должно изменяться в каждом из этих трех сценариев.

Знакомство с createAsyncThunk

createAsyncThunk это утилита, которая создает thunk action creator. Звучит сложно? Давай разберемся на пальцах.

Thunk это функция, которая возвращает другую функцию. createAsyncThunk создает такую функцию-обертку для нашего асинхронного запроса. При вызове эта обертка автоматически диспатчит соответствующие экшены на разных этапах жизни нашего запроса.

Сигнатура функции выглядит так:

javascript
const asyncThunk = createAsyncThunk(
  // 1. Префикс типа экшена (String)
  'users/fetchUsers',
  
  // 2. "Полезная нагрузка" (payload creator) - асинхронная функция
  async (arg, thunkAPI) => {
    // Здесь делаем асинхронный запрос (например, на API)
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    // Возвращаемые данные станут payload экшена `fulfilled`
    return await response.json();
  }
);

Разберем аргументы:

  • Первый аргумент это строка, префикс для генерируемых типов экшенов. Для нашего примера 'users/fetchUsers' createAsyncThunk сгенерирует три типа экшенов:

    1. 'users/fetchUsers/pending'

    2. 'users/fetchUsers/fulfilled'

    3. 'users/fetchUsers/rejected'
      Нам не нужно создавать эти типы вручную!

  • Второй аргумент это «создатель полезной нагрузки» (payload creator). Это асинхронная функция, которая содержит всю логику side effect’а (например запрос к API).

    1. Она получает два аргумента. arg (любые данные, которые ты передашь thunk’у при вызове, например, ID пользователя) и thunkAPI (объект, содержащий методы как dispatchgetStaterejectWithValue и другие, полезные для более сложных сценариев).

    2. Если функция выполняется успешно и возвращает данные, они становятся action.payload в экшене fulfilled.

    3. Если функция выбрасывает ошибку (например, упал запрос), эта ошибка становится action.error в экшене rejected.

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

Три жизненных цикла асинхронного запроса

Любой асинхронный запрос имеет три фундаментальных состояния и createAsyncThunk формализует их в виде трех экшенов. Давай пройдемся по каждому из них и подумаем, как мы обычно хотим обновлять наше состояние приложения в ответ на них.

  • pending (ожидание). Запрос отправлен, но ответ еще не получен. В этот момент мы обычно хотим:

    • Установить флаг isLoading в true, чтобы показать индикатор загрузки (например, спиннер).

    • Сбросить флаг isError (если предыдущий запрос завершился ошибкой).

    • Очистить данные (опционально, зависит от логики приложения).

  • fulfilled (выполнено). Запрос завершился успешно и мы получили данные от сервера. Это счастливый сценарий, в котором мы:

    • Устанавливаем флаг isLoading обратно в false (скрываем спиннер).

    • Записываем полученные данные (которые находятся в action.payload) в состояние.

    • Устанавливаем флаг isError в false.

  • rejected (отклонено). Запрос завершился с ошибкой (сервер вернул 500, пропал интернет и т.д.). В этом случае мы:

    • Устанавливаем isLoading в false (скрываем спиннер, ведь загрузка больше не идет).

    • Сохраняем информацию об ошибке (обычно из action.error.message или с помощью rejectWithValue) в состояние, чтобы показать ее пользователю.

    • Устанавливаем флаг isError в true.

Теперь ключевой вопрос. Как мы «ловим» эти автоматически сгенерированные экшены и обновляем состояние в ответ на них? Для этого в слайсах используется специальное поле extraReducers.

Обработка жизненных циклов в слайсе с помощью extraReducers

Помнишь, в нашем слайсе для счетчика мы использовали поле reducers? Оно создает «редьюсеры» для экшенов, которые «принадлежат» этому слайсу и генерируются createSlice автоматически.

extraReducers это такое же поле в createSlice, но оно предназначено для обработки экшенов, которые не были сгенерированы самим createSlice. Это идеальное место для того, чтобы реагировать на экшены от createAsyncThunk и других слайсов.

Мы можем описать extraReducers используя «builder callback» нотацию. Это современный и рекомендованный способ, который обеспечивает лучшую TypeScript-типизацию.

javascript
const usersSlice = createSlice({
  name: 'users',
  initialState: {
    items: [],
    isLoading: false,
    isError: false,
    error: '',
  },
  reducers: {
    // Здесь наши синхронные редьюсеры...
  },
  extraReducers: (builder) => {
    // Будем добавлять обработчики через builder.addCase
  }
});

Внутри extraReducers мы используем builder.addCase() чтобы «подписать» наш слайс на обработку конкретных типов экшенов. Давай напишем обработчики для всех трех состояний нашего thunk’а.

Создаем слайс для загрузки пользователей с JSONPlaceholder

Давай соберем все знания воедино и создадим полноценный слайс для загрузки и отображения списка пользователей. В качестве учебного API мы будем использовать сервис JSONPlaceholder, который предоставляет фейковые данные для тестирования.

Шаг 1: Создаем асинхронный thunk

Сначала создадим сам thunk для загрузки пользователей. Обычно его объявляют прямо в файле слайса, перед createSlice.

javascript
// features/users/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// Создаем асинхронный thunk
export const fetchUsers = createAsyncThunk(
  'users/fetchUsers',
  async (_, thunkAPI) => {
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/users');
      
      // fetch не выбрасывает ошибку для HTTP статусов 4xx/5xx, поэтому проверяем вручную
      if (!response.ok) {
        throw new Error('Failed to fetch users');
      }
      
      const data = await response.json();
      return data; // Это станет action.payload в fulfilled
    } catch (error) {
      // Можно использовать thunkAPI.rejectWithValue для передачи структурированных данных об ошибке
      return thunkAPI.rejectWithValue(error.message);
    }
  }
);

Обрати внимание:

  • Первый аргумент arg нам не нужен, поэтому мы заменяем его на _.

  • Мы используем try/catch чтобы перехватить возможные ошибки сети или парсинга JSON.

  • В случае ошибки, мы используем thunkAPI.rejectWithValue чтобы вернуть кастомное значение, которое станет action.payload в экшене rejected (это часто удобнее, чем стандартный объект ошибки).

Шаг 2: Создаем слайс с extraReducers

Теперь опишем начальное состояние и то, как оно будет меняться при диспатче наших асинхронных экшенов.

javascript
const usersSlice = createSlice({
  name: 'users',
  initialState: {
    items: [],
    isLoading: false,
    isError: false,
    error: '',
  },
  reducers: {
    // Можно добавить синхронные редьюсеры, например, для очистки ошибки
    clearError: (state) => {
      state.isError = false;
      state.error = '';
    }
  },
  extraReducers: (builder) => {
    builder
      // Обработка начала загрузки (pending)
      .addCase(fetchUsers.pending, (state) => {
        state.isLoading = true;
        state.isError = false; // Сбрасываем флаг ошибки при новом запросе
        state.error = '';
      })
      // Обработка успешной загрузки (fulfilled)
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.isLoading = false;
        state.isError = false;
        // Записываем массив пользователей, пришедший с сервера, в state.items
        state.items = action.payload;
      })
      // Обработка ошибки (rejected)
      .addCase(fetchUsers.rejected, (state, action) => {
        state.isLoading = false;
        state.isError = true;
        // Сохраняем сообщение об ошибке. Используем action.payload, т.к. использовали rejectWithValue
        state.error = action.payload || 'Something went wrong';
      });
  },
});

// Экспортируем сгенерированный редьюсер (для стора) и синхронные экшены
export const { clearError } = usersSlice.actions;
export default usersSlice.reducer;

Наш слайс готов обрабатывать все стадии асинхронного запроса. Благодаря Immer (который встроен в RTK), мы можем обновлять состояние мутабельным образом, как в примере state.isLoading = true, но под капотом это превращается в корректное иммутабельное обновление.

Шаг 3: Используем thunk в React-компоненте

Осталось самое приятное, использовать наш асинхронный экшен в компоненте. Для этого мы воспользуемся уже знакомыми хуками useDispatch и useSelector.

jsx
// features/users/UserList.jsx
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchUsers, clearError } from './usersSlice';

const UserList = () => {
  const dispatch = useDispatch();
  // Выбираем нужные части состояния из стора
  const { items: users, isLoading, isError, error } = useSelector((state) => state.users);

  // Эффект для загрузки пользователей при монтировании компонента
  useEffect(() => {
    dispatch(fetchUsers());
  }, [dispatch]);

  const handleRetry = () => {
    dispatch(clearError());
    dispatch(fetchUsers());
  };

  // Рендерим состояние загрузки
  if (isLoading) {
    return <div className="loading">Загрузка пользователей...</div>;
  }

  // Рендерим состояние ошибки
  if (isError) {
    return (
      <div className="error">
        <p>Ошибка при загрузке: {error}</p>
        <button onClick={handleRetry}>Попробовать снова</button>
      </div>
    );
  }

  // Рендерим список пользователей
  return (
    <div>
      <h2>Список пользователей</h2>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <strong>{user.name}</strong> ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
};

export default UserList;

Что происходит в компоненте:

  1. Мы импортируем наш thunk fetchUsers и синхронный экшен clearError.

  2. С помощью useSelector подписываемся на часть состояния state.users.

  3. В useEffect при первом рендере диспатчим fetchUsers(). Это запускает весь жизненный цикл thunk’а.

  4. В зависимости от состояния (isLoadingisError), мы рендерим разный UI: индикатор загрузки, сообщение об ошибке с кнопкой для повтора или, наконец, сам список пользователей.

Это классический и очень надежный паттерн для работы с асинхронными данными в Redux.

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

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

Задание 1: Базовая интеграция

  1. Создай новый проект или открой существующий, где настроены Redux Toolkit и React.

  2. Реализуй слайс для загрузки пользователей в точности так, как показано в уроке.

  3. Создай компонент UserList и подключи его в свое приложение. Убедись, что список пользователей корректно загружается и отображается.

Задание 2: Обработка деталей пользователя

  1. Расширь свой thunk fetchUsers. Добавь второй thunk под названием fetchUserById, который будет принимать userId в качестве аргумента и загружать данные одного пользователя по эндпоинту https://jsonplaceholder.typicode.com/users/${userId}.

  2. Создай в слайсе новое состояние для хранения данных об одном пользователе (например, currentUser: nullisUserLoading: false и т.д.).

  3. С помощью extraReducers обработай все три состояния для fetchUserById.

  4. Создай новый компонент UserDetails, который по нажатию на пользователя в списке (или по ссылке) загружает и показывает его детальную информацию.

Задание 3: Улучшение UX

  1. Добавь в состояние слайса флаг hasFetched (булевый). Меняй его на true только при успешной загрузке (fulfilled).

  2. Измени логику в useEffect компонента UserList: если hasFetched === true, не делать повторный запрос при монтировании. Это поможет избежать лишних запросов при переходе между страницами, если данные уже загружены.

  3. В компоненте ошибки добавь кнопку «Повторить», как показано в примере, которая сбрасывает ошибку и заново запускает запрос.

Задание 4: Работа с формами и отправка данных

  1. Создай новый thunk addNewUser с помощью createAsyncThunk. Он должен имитировать отправку POST-запроса для создания нового пользователя. Поскольку JSONPlaceholder не сохраняет данные, можно использовать https://jsonplaceholder.typicode.com/users как POST-эндпоинт. Он вернет тебе фиктивный ответ, как будто пользователь создан.

  2. В payload creator передавай объект с данными нового пользователя (name, email).

  3. Обработай addNewUser.fulfilled в extraReducers. В этом обработчике «добавь» нового пользователя (из action.payload) в массив state.items. Подсказка: Так как это фейковый API, у нового пользователя не будет id от сервера. Можешь сгенерировать временный id на клиенте, прежде чем «отправлять» его.

  4. Создай компонент AddUserForm с формой для ввода имени и почты. При сабмите формы диспатчь addNewUser с данными из формы.

Итоги 9-го урока

Ты только что освоил один из самых основных инструментов в Redux Toolkit, это createAsyncThunk. Теперь ты умеешь:

  • Понимать проблему асинхронности в Redux.

  • Создавать асинхронные операции с помощью createAsyncThunk.

  • Обрабатывать три жизненных цикла запроса (pending, fulfilled, rejected) в extraReducers слайса.

  • Строить надежные интерфейсы, которые корректно реагируют на состояние загрузки и возможные ошибки.

Это навык для создания настоящих, живых приложений. В следующих уроках мы поговорим о структуре больших проектов и познакомимся с еще более продвинутых инструментом для работы с API, это RTK Query, который во многих случаях может полностью заменить createAsyncThunk. Но понимание thunk’ов это основа, которая поможет теглубоко понять и оценить преимущества RTK Query.

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

Полный курс с уроками по Redux и Redux Toolkit для начинающих

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

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

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