State Management

Async через createAsyncThunk

Страница профиля грузит данные пользователя с сервера. Нужно показать спиннер во время запроса, данные при успехе и сообщение при ошибке. В чистом Redux это три отдельных action: один до запроса, один на успех, один на провал, и три ветки редьюсера, чтобы вручную выставлять флаги loading и error. createAsyncThunk забирает эту рутину: из одной асинхронной функции он сам порождает три состояния запроса - pending, fulfilled, rejected - и остаётся только описать, что делать с состоянием в каждом из них.

  • Загрузка профиля, ленты или списка заказов с показом спиннера, данных и ошибки
  • Отправка формы на сервер с блокировкой кнопки на время запроса и сообщением об ошибке
  • Дашборды, где несколько запросов грузятся параллельно и у каждого свой статус
  • Повторные попытки и отмена устаревших запросов через жизненный цикл thunk
  • Производный флаг загрузки в UI, выведенный из статуса запроса, а не хранимый отдельно вручную

Предварительные знания

  • createSlice и configureStore: как RTK генерирует action и reducer
  • extraReducers как способ реагировать на action, объявленные вне slice
  • Промисы и async/await: жизненный цикл асинхронной операции
  • Redux Toolkit: slices и Immer

createAsyncThunk и три состояния запроса

createAsyncThunk принимает строковый префикс типа action и асинхронную функцию-исполнитель, которая возвращает промис. На основе этого он создаёт thunk, который при dispatch проходит через три фазы. Когда промис стартует, отправляется action с суффиксом pending. При успешном разрешении отправляется fulfilled с результатом в payload. При отклонении отправляется rejected с информацией об ошибке.

Дженерики задают контракт thunk: первый параметр это тип возвращаемых данных, который попадёт в payload при fulfilled, второй это тип аргумента, переданного при вызове. Здесь fetchUser ожидает строковый userId и обещает вернуть User. Из префикса user/fetch RTK выводит три типа action: user/fetch/pending, user/fetch/fulfilled и user/fetch/rejected.

ФазаКогда отправляетсяЧто в payload
pendingСразу при старте промисаАргументы запроса (meta)
fulfilledПри успешном разрешенииВозвращённые данные
rejectedПри выбросе или отклоненииИнформация об ошибке

Исполнитель thunk это место для самого запроса, а не для изменения состояния. Он лишь возвращает данные или бросает ошибку. Что именно записать в store на каждую фазу, описывается отдельно в extraReducers slice. Такое разделение держит запрос и переход состояния в разных местах.

Сколько и какие action порождает один createAsyncThunk при выполнении?

Подключение через extraReducers

Action thunk объявлены вне slice, поэтому обрабатываются не в reducers, а в extraReducers. Эта секция получает builder, у которого есть метод addCase для каждой фазы. К каждому из трёх состояний thunk привязывается свой обработчик: на pending выставляется статус загрузки, на fulfilled данные кладутся в состояние, на rejected сохраняется ошибка.

Внутри обработчиков снова работает мутирующий синтаксис Immer: state.status и state.data меняются напрямую, а корректное новое состояние собирается под капотом. В fulfilled action.payload строго типизирован тем User, что обещал thunk. В rejected текст ошибки лежит в action.error.message, и optional chaining с запасным значением закрывает случай отсутствующего сообщения.

Обработчики thunk идут именно в extraReducers, а не в reducers. Секция reducers порождает собственные action и создателей, а extraReducers только реагирует на уже существующие action, объявленные снаружи, включая фазы thunk. Попытка положить fetchUser.fulfilled в reducers не сработает: эти action создаёт thunk, а не slice.

Почему фазы thunk (pending, fulfilled, rejected) обрабатываются в extraReducers, а не в reducers?

Выведение загрузки и ошибки

Имея в состоянии поля status, data и error, UI не хранит отдельных ручных флагов вроде isLoading. Они выводятся из единого статуса. status равен loading значит идёт запрос, succeeded значит есть данные, failed значит произошла ошибка. Один источник истины про фазу запроса исключает рассинхрон, когда флаг загрузки остался true, а данные уже пришли.

Компонент диспатчит thunk в эффекте и читает три поля из store. Дальше всё это разворачивается в три ветки рендера: спиннер, ошибка, данные. Никакого отдельного useState для загрузки: статус приходит из того же store, что и данные, и обновляется теми же тремя фазами thunk.

  • Ручные флаги — Отдельные isLoading и hasError, выставляемые вручную в разных местах. Легко рассинхронить: загрузка закончилась, а флаг остался
  • Производный статус — Один status из жизненного цикла thunk, из него выводятся загрузка, ошибка, данные. Рассинхрон невозможен: источник один

Хранить производное состояние как производное, а не дублировать его это общий принцип. Флаг загрузки это функция от статуса запроса, поэтому он вычисляется, а не запоминается. Этот же приём масштабируется на несколько запросов: у каждого свой статус, и UI комбинирует их без отдельных булевых переменных.

Почему предпочтительнее выводить флаг загрузки из поля status, а не держать отдельный isLoading?

Диспатч thunk и обработка результата

dispatch(fetchUser(userId)) возвращает промис, который разрешается результатом thunk. По умолчанию этот промис не отклоняется на ошибке запроса: rejected обрабатывается внутри store через extraReducers, а наружу приходит специальный объект-результат. Если же по месту вызова нужно отреагировать на успех или провал прямо в компоненте, к промису применяют unwrap.

unwrap превращает результат в обычный промис, который отдаёт данные при успехе и бросает при ошибке. Это удобно, когда после запроса нужно сделать что-то локальное: показать уведомление, перейти на другую страницу, очистить форму. Состояние при этом всё равно обновилось через extraReducers, unwrap лишь даёт точку для побочной реакции в самом компоненте.

У исполнителя thunk есть второй аргумент thunkAPI с полезными методами: getState для чтения текущего состояния, dispatch для отправки других action, rejectWithValue для возврата своей структуры ошибки вместо стандартной и signal для отмены. Эти инструменты покрывают сложные случаи: условный запрос, своя форма ошибки, отмена устаревшего запроса.

На этом ручная работа с асинхронностью в RTK заканчивается. createAsyncThunk хорошо подходит для разовых операций и кастомной логики. Но если задача это типовая загрузка серверных данных с кэшированием и инвалидацией, тот же цикл pending/fulfilled/rejected целиком берёт на себя RTK Query, и это тема следующих уроков.

Зачем применять unwrap к результату dispatch(thunk)?

Связь с другими темами

Асинхронные thunk это слой над slice и предшественник RTK Query:

  • Redux Toolkit: slices и Immer — Thunk не отдельная сущность: его три состояния обрабатываются в extraReducers того же slice
  • RTK Query: server-state в Redux — RTK Query берёт ровно этот цикл pending/fulfilled/rejected и автоматизирует его вместе с кэшем для серверных данных

Итог

  • createAsyncThunk оборачивает асинхронную функцию и сам порождает три action: pending, fulfilled, rejected
  • Эти три состояния обрабатываются в extraReducers slice, а не в обычных reducers
  • pending выставляет статус загрузки, fulfilled кладёт данные из payload, rejected сохраняет ошибку
  • Флаг загрузки в UI это производное от статуса запроса, его не нужно держать отдельной ручной переменной
  • Аргумент thunk и тип возвращаемых данных типизируются дженериками, поэтому payload в fulfilled строго типизирован
  • Тот же цикл pending/fulfilled/rejected позже автоматизирует RTK Query для серверного состояния

Связанные уроки

  • sm-12-rtk-slices — createAsyncThunk подключается к slice через extraReducers: без понимания slice и редьюсеров асинхронность не собрать
  • sm-15-rtk-query — RTK Query автоматизирует ровно тот цикл pending/fulfilled/rejected, который здесь пишется руками для серверных данных
Async через createAsyncThunk

0

1

Войти