Redux Essentials, Часть 5: Асинхронная логика и получение данных
Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →
- Как использовать middleware "thunk" в Redux для асинхронной логики
- Паттерны обработки состояния асинхронных запросов
- Как использовать API Redux Toolkit
createAsyncThunkдля управления асинхронными вызовами
- Знакомство с использованием HTTP-запросов для получения и обновления данных через REST API сервера
Введение
В Части 4: Использование данных Redux мы рассмотрели, как использовать несколько фрагментов данных из хранилища Redux в React-компонентах, настраивать содержимое объектов действий перед их диспетчеризацией и обрабатывать более сложную логику обновления в редюсерах.
До сих пор все данные, с которыми мы работали, находились непосредственно в нашем React-клиенте. Однако большинству реальных приложений необходимо взаимодействовать с данными с сервера, выполняя HTTP-вызовы API для получения и сохранения элементов.
В этом разделе мы преобразуем наше приложение социальной сети для получения данных о постах и пользователях через API, а также будем добавлять новые посты, сохраняя их через API.
Redux Toolkit включает API для получения данных и кэширования RTK Query. RTK Query — это специализированное решение для получения данных и кэширования в Redux-приложениях, которое может полностью устранить необходимость написания дополнительной логики Redux, такой как thunks или редюсеры, для управления получением данных. Мы специально рассматриваем RTK Query как стандартный подход к получению данных.
RTK Query построен на тех же шаблонах, что показаны на этой странице, поэтому этот раздел поможет вам понять базовые механизмы работы получения данных в Redux.
Мы начнём изучать RTK Query в Части 7: Основы RTK Query.
Пример REST API и клиента
Чтобы пример проекта был изолированным, но реалистичным, начальная настройка уже включает фейковое in-memory REST API для наших данных (настроенное с помощью инструмента Mock Service Worker). API использует /fakeApi как базовый URL для эндпоинтов и поддерживает стандартные HTTP-методы GET/POST/PUT/DELETE для /fakeApi/posts, /fakeApi/users и fakeApi/notifications. Он определён в src/api/server.ts.
Проект также содержит небольшой HTTP API-клиент, который предоставляет методы client.get() и client.post(), аналогичные популярным HTTP-библиотекам вроде axios. Он определён в src/api/client.ts.
В этом разделе мы будем использовать объект client для выполнения HTTP-вызовов к нашему фейковому in-memory REST API.
Кроме того, моковый сервер настроен на повторное использование одного и того же случайного сида при каждой загрузке страницы, чтобы генерировать одинаковый список фейковых пользователей и постов. Если вы хотите сбросить это, удалите значение 'randomTimestampSeed' в Local Storage вашего браузера и перезагрузите страницу, или отключите эту функцию, отредактировав src/api/server.ts и установив useSeededRNG в false.
Напоминаем, что примеры кода сосредоточены на ключевых концепциях и изменениях для каждого раздела. Полные изменения приложения смотрите в проектах CodeSandbox и ветке tutorial-steps-ts в репозитории проекта.
Использование Middleware для включения асинхронной логики
Само по себе хранилище Redux ничего не знает об асинхронной логике. Оно умеет только синхронно диспетчеризовать действия, обновлять состояние путём вызова корневого редюсера и уведомлять UI об изменениях. Любая асинхронность должна происходить вне хранилища.
Но что, если вам нужно организовать взаимодействие асинхронной логики со хранилищем через диспетчеризацию действий, проверку текущего состояния или выполнение побочных эффектов? Именно для этого существуют промежуточные слои Redux (middleware). Они расширяют функциональность хранилища, позволяя:
-
Выполнять дополнительную логику при диспетчеризации любого действия (например, логирование действий и состояния)
-
Приостанавливать, модифицировать, задерживать, заменять или отменять диспетчеризуемые действия
-
Писать дополнительный код с доступом к
dispatchиgetState -
Обучать
dispatchработать не только с объектами действий, но и с функциями, промисами, преобразуя их в стандартные действия -
Реализовывать логику с асинхронными операциями или побочными эффектами
Наиболее частая причина использования middleware — организация взаимодействия асинхронной логики с хранилищем. Это позволяет писать код, диспетчеризующий действия и проверяющий состояние хранилища, сохраняя его отделённым от UI-логики.
Подробнее о кастомизации хранилища через middleware:
Middleware и поток данных в Redux
Ранее мы рассматривали синхронный поток данных в Redux.
Middleware изменяют этот поток, добавляя дополнительный этап перед dispatch. Это позволяет middleware выполнять логику (например HTTP-запросы) перед диспетчеризацией действий. Асинхронный поток данных выглядит так:

Thunks и асинхронная логика
Для Redux существует множество асинхронных middleware с разным синтаксисом. Наиболее популярный — redux-thunk, позволяющий писать простые функции с прямой асинхронной логикой. Функция configureStore из Redux Toolkit по умолчанию автоматически подключает thunk-middleware, и мы рекомендуем использовать thunks как стандартный подход для асинхронной логики в Redux.
Термин "thunk" в программировании означает "фрагмент кода, выполняющий отложенную работу".
Подробнее об использовании thunks в Redux:
Дополнительные материалы:
Функции Thunk
После подключения thunk-middleware в хранилище Redux, вы можете передавать функции thunk напрямую в store.dispatch. Thunk-функция всегда вызывается с аргументами (dispatch, getState), которые можно использовать внутри неё.
Thunk-функция может содержать любую логику — синхронную или асинхронную.
Thunks обычно диспетчеризуют стандартные действия через action creators, например dispatch(increment()):
const store = configureStore({ reducer: counterReducer })
const exampleThunkFunction = (
dispatch: AppDispatch,
getState: () => RootState
) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(increment())
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
store.dispatch(exampleThunkFunction)
Для согласованности с диспетчеризацией обычных объектов действий, thunks обычно создаются через thunk action creators — функции, возвращающие thunk. Эти создатели действий могут принимать параметры для использования внутри thunk.
const logAndAdd = (amount: number) => {
return (dispatch: AppDispatch, getState: () => RootState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}
store.dispatch(logAndAdd(5))
Thunk-функции обычно пишутся в файлах слайсов (slice), поскольку логика получения данных в thunk обычно концептуально связана с логикой обновления конкретного слайса. В этом разделе мы рассмотрим несколько различных способов определения thunk-функций.
Написание асинхронных thunk-функций
Thunk-функции могут содержать асинхронную логику, такую как setTimeout, Promises и async/await. Это делает их удобным местом для размещения HTTP-запросов к серверному API.
Логика получения данных в Redux обычно следует предсказуемому шаблону:
-
Перед запросом диспатчится действие "start" (начало), указывающее на выполнение запроса. Это может использоваться для отслеживания состояния загрузки, чтобы пропускать дублирующиеся запросы или показывать индикаторы загрузки в интерфейсе.
-
Асинхронный запрос выполняется с помощью
fetchили обёрточной библиотеки, возвращая промис с результатом. -
Когда промис запроса разрешается, асинхронная логика диспатчит либо действие "success" (успех) с полученными данными, либо действие "failure" (ошибка) с деталями ошибки. Логика редюсера в обоих случаях сбрасывает состояние загрузки и либо обрабатывает данные при успехе, либо сохраняет ошибку для возможного отображения.
Эти шаги не обязательны, но широко используются. (Если вас интересует только успешный результат, можно просто диспатчить одно действие "success" по завершении запроса, пропуская действия "start" и "failure").
Redux Toolkit предоставляет API createAsyncThunk для создания и диспатчинга действий, описывающих асинхронный запрос.
Базовое использование createAsyncThunk выглядит так:
import { createAsyncThunk } from '@reduxjs/toolkit'
export const fetchItemById = createAsyncThunk(
'items/fetchItemById',
async (itemId: string) => {
const item = await someHttpRequest(itemId)
return item
}
)
Подробнее о том, как createAsyncThunk упрощает код для диспатчинга действий асинхронных запросов, смотрите в соответствующем разделе. Скоро мы увидим его практическое применение.
Detailed Explanation: Dispatching Request Status Actions in Thunks
If we were to write out the code for a typical async thunk by hand, it might look like this:
const getRepoDetailsStarted = () => ({
type: 'repoDetails/fetchStarted'
})
const getRepoDetailsSuccess = (repoDetails: RepoDetails) => ({
type: 'repoDetails/fetchSucceeded',
payload: repoDetails
})
const getRepoDetailsFailed = (error: any) => ({
type: 'repoDetails/fetchFailed',
error
})
const fetchIssuesCount = (org: string, repo: string) => {
return async (dispatch: AppDispatch) => {
dispatch(getRepoDetailsStarted())
try {
const repoDetails = await getRepoDetails(org, repo)
dispatch(getRepoDetailsSuccess(repoDetails))
} catch (err) {
dispatch(getRepoDetailsFailed(err.toString()))
}
}
}
However, writing code using this approach is tedious. Each separate type of request needs repeated similar implementation:
- Unique action types need to be defined for the three different cases
- Each of those action types usually has a corresponding action creator function
- A thunk has to be written that dispatches the correct actions in the right sequence
createAsyncThunk abstracts this pattern by generating the action types and action creators, and generating a thunk that dispatches those actions automatically. You provide a callback function that makes the async call and returns a Promise with the result.
It's also easy to make mistakes with error handling when writing thunk logic yourself. In this case, the try block will actually catch errors from both a failed request, and any errors while dispatching. Handling this correctly would require restructuring the logic to separate those. createAsyncThunk already handles errors correctly for you internally.
Типизация Redux Thunk-функций
Типизация thunk-функций, написанных вручную
При ручном написании thunk можно явно указать тип аргументов как (dispatch: AppDispatch, getState: () => RootState). Поскольку это распространённая практика, вы также можете определить переиспользуемый тип AppThunk и использовать его:
import { Action, ThunkAction, configureStore } from '@reduxjs/toolkit'
// omit actual store setup
// Infer the type of `store`
export type AppStore = typeof store
// Infer the `AppDispatch` type from the store itself
export type AppDispatch = typeof store.dispatch
// Same for the `RootState` type
export type RootState = ReturnType<typeof store.getState>
// Export a reusable type for handwritten thunks
export type AppThunk = ThunkAction<void, RootState, unknown, Action>
Затем вы можете использовать его для описания создаваемых thunk-функций:
// Use `AppThunk` as the return type, since we return a thunk function
const logAndAdd = (amount: number): AppThunk => {
return (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}
Типизация createAsyncThunk
Для createAsyncThunk: если ваша функция полезной нагрузки (payload) принимает аргумент, укажите тип для этого аргумента, например async (userId: string). Возвращаемый тип указывать не обязательно — TS автоматически выведет его.
Если внутри createAsyncThunk нужен доступ к dispatch или getState, RTK позволяет создать "предварительно типизированную" версию с правильными типами dispatch и getState через вызов createAsyncThunk.withTypes(), аналогично тому, как мы создавали предварительно типизированные версии useSelector и useDispatch. Мы создадим новый файл src/app/withTypes и экспортируем оттуда:
import { createAsyncThunk } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState
dispatch: AppDispatch
}>()
Подробнее о типизации thunk в TypeScript см.:
Загрузка постов
До сих пор наш postsSlice использовал жёстко заданные примеры данных в качестве начального состояния. Мы изменим это, начав с пустого массива постов, а затем загрузим список постов с сервера.
Для этого нам потребуется изменить структуру состояния в postsSlice, чтобы отслеживать текущий статус API-запроса.
Состояние загрузки для запросов
При выполнении API-вызова его прогресс можно представить как конечный автомат с четырьмя возможными состояниями:
-
Запрос ещё не начался
-
Запрос выполняется
-
Запрос успешно выполнен, и теперь у нас есть необходимые данные
-
Запрос не удался, и, вероятно, есть сообщение об ошибке
Мы могли бы отслеживать эту информацию с помощью булевых флагов, например isLoading: true, но лучше представить эти состояния как единое объединённое значение. Хороший шаблон для этого — иметь секцию состояния, которая выглядит так (используя нотацию строковых объединённых типов TypeScript):
{
// Multiple possible status string union values
status: 'idle' | 'pending' | 'succeeded' | 'failed',
error: string | null
}
Эти поля будут существовать наряду с любыми фактическими данными, которые хранятся. Эти конкретные названия состояний не обязательны — вы можете использовать другие имена, если хотите, например, 'loading' вместо 'pending' или 'completed' вместо 'succeeded'.
Мы можем использовать эту информацию, чтобы решить, что показывать в нашем интерфейсе по мере выполнения запроса, а также добавить логику в наши редюсеры, чтобы предотвратить такие случаи, как повторная загрузка данных.
Давайте обновим наш postsSlice, чтобы использовать этот шаблон для отслеживания состояния загрузки при запросе "fetch posts". Мы изменим наше состояние с простого массива постов на структуру вида {posts, status, error}. Также удалим старые примеры постов из начального состояния и добавим несколько новых селекторов для полей загрузки и ошибок:
import { createSlice, nanoid } from '@reduxjs/toolkit'
// omit reactions and other types
interface PostsState {
posts: Post[]
status: 'idle' | 'pending' | 'succeeded' | 'failed'
error: string | null
}
const initialState: PostsState = {
posts: [],
status: 'idle',
error: null
}
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.posts.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
// omit prepare logic
}
},
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.posts.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
},
extraReducers: builder => {
builder.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
export const selectAllPosts = (state: RootState) => state.posts.posts
export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)
export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error
В рамках этого изменения нам также нужно заменить все случаи использования state как массива на state.posts, потому что массив теперь находится на один уровень глубже.
Да, это действительно означает, что теперь у нас есть вложенный путь к объекту, который выглядит как state.posts.posts, что несколько избыточно и глупо :) Мы могли бы изменить имя вложенного массива на items или data или что-то подобное, чтобы избежать этого, но пока оставим как есть.
Получение данных с помощью createAsyncThunk
API createAsyncThunk из Redux Toolkit генерирует thunk'и, которые автоматически диспатчат действия "start/success/failure" за вас.
Начнём с добавления thunk'а, который будет выполнять HTTP-запрос для получения списка постов. Импортируем утилиту client из папки src/api и используем её для запроса к '/fakeApi/posts'.
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
// omit other imports and types
export const fetchPosts = createAppAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
})
const initialState: PostsState = {
posts: [],
status: 'idle',
error: null
}
createAsyncThunk принимает два аргумента:
-
Строку, которая будет использоваться как префикс для генерируемых типов действий
-
Функцию обратного вызова "payload creator", которая должна возвращать Promise с данными или отклонённый Promise с ошибкой
Payload creator обычно выполняет HTTP-запрос и может либо вернуть Promise из HTTP-запроса напрямую, либо извлечь данные из ответа API и вернуть их. Обычно мы пишем это, используя синтаксис JS async/await, который позволяет писать функции, использующие промисы, со стандартной логикой try/catch вместо цепочек somePromise.then().
В данном случае мы передаём 'posts/fetchPosts' в качестве префикса типа действия.
В этом случае функция обратного вызова для fetchPosts не требует аргументов, и всё, что ей нужно сделать, — это дождаться ответа от API-вызова. Объект ответа выглядит как {data: []}, и мы хотим, чтобы отправляемое Redux-действие имело payload, который представляет собой просто массив постов. Поэтому мы извлекаем response.data и возвращаем его из функции обратного вызова.
Если мы попробуем вызвать dispatch(fetchPosts()), thunk fetchPosts сначала отправит действие типа 'posts/fetchPosts/pending':

Мы можем обрабатывать это действие в нашем редюсере и помечать статус запроса как 'pending'.
Как только Promise разрешится, thunk fetchPosts возьмёт массив response.data, который мы вернули из функции обратного вызова, и отправит действие 'posts/fetchPosts/fulfilled' с массивом постов в качестве action.payload:

Редьюсеры и действия загрузки
Далее нам нужно обработать оба этих действия в наших редьюсерах. Это требует более глубокого понимания API createSlice, которое мы использовали.
Мы уже видели, что createSlice генерирует создателей действий для каждой функции-редьюсера, определённой в поле reducers, и что сгенерированные типы действий включают имя слайса, например:
console.log(
postUpdated({ id: '123', title: 'First Post', content: 'Some text here' })
)
/*
{
type: 'posts/postUpdated',
payload: {
id: '123',
title: 'First Post',
content: 'Some text here'
}
}
*/
Мы также видели, что можем использовать поле extraReducers в createSlice для реагирования на действия, определённые вне слайса.
В этом случае нам нужно прослушивать типы действий "pending" (ожидание) и "fulfilled" (выполнено), отправленные нашим thunk-действием fetchPosts. Эти создатели действий прикреплены к самой функции fetchPost, и мы можем передать их в extraReducers для обработки:
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
})
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit existing reducers here
},
extraReducers: builder => {
builder
.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
.addCase(fetchPosts.pending, (state, action) => {
state.status = 'pending'
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts.push(...action.payload)
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message ?? 'Unknown Error'
})
}
})
Мы обработаем все три типа действий, которые может отправить thunk, в зависимости от возвращённого Promise:
-
Когда запрос начинается, мы установим
statusв'pending' -
При успешном запросе пометим
statusкак'succeeded'(успешно) и добавим полученные посты вstate.posts -
При ошибке запроса пометим
statusкак'failed'(ошибка) и сохраним сообщение об ошибке в состояние для отображения
Отправка thunk-действий из компонентов
Теперь, когда у нас есть thunk fetchPosts и редьюсер обновлён для обработки этих действий, обновим компонент <PostsList>, чтобы он инициировал получение данных.
Импортируем thunk fetchPosts в компонент. Как и для других создателей действий, нам нужно его отправить, поэтому добавим хук useAppDispatch. Поскольку мы хотим получать данные при монтировании <PostsList>, импортируем хук React useEffect и отправим действие.
Важно, чтобы мы пытались получить список постов только один раз. Если мы будем делать это каждый раз при рендеринге компонента <PostsList> или его пересоздании из-за переключения между вкладками, мы можем получить несколько запросов. Мы можем использовать значение posts.status, чтобы решить, нужно ли начинать загрузку: выберем его в компоненте и начнём загрузку только если статус 'idle' (ещё не начат).
import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { TimeAgo } from '@/components/TimeAgo'
import { PostAuthor } from './PostAuthor'
import { ReactionButtons } from './ReactionButtons'
import { fetchPosts, selectAllPosts, selectPostsStatus } from './postsSlice'
export const PostsList = () => {
const dispatch = useAppDispatch()
const posts = useAppSelector(selectAllPosts)
const postStatus = useAppSelector(selectPostsStatus)
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
// omit rendering logic
}
Теперь после входа в приложение мы должны увидеть свежий список постов!

Предотвращение дублирования запросов
Хорошая новость: мы успешно получили объекты постов с нашего mock-сервера API.
К сожалению, есть проблема: сейчас в списке постов отображаются дубликаты:

Если посмотреть в Redux DevTools, можно увидеть, что было отправлено два набора действий 'pending' и 'fulfilled':

Почему так? Разве мы не добавили проверку postStatus === 'idle'? Разве этого недостаточно для гарантии однократной отправки thunk?
Ну, да... и нет :)
Сама логика в useEffect здесь верна. Проблема в том, что сейчас мы используем development-сборку нашего приложения, а в режиме разработки React запускает все хуки useEffect дважды при монтировании внутри компонента <StrictMode>, чтобы сделать определённые ошибки более заметными.
В данном случае произошло следующее:
-
Компонент
<PostsList>смонтировался -
Хук
useEffectвыполнился в первый раз. ЗначениеpostStatusравно'idle', поэтому он диспатчит thunkfetchPosts. -
fetchPostsнемедленно диспатчит действиеfetchPosts.pending, поэтому Redux-хранилище действительно обновило статус на'pending'... -
но React запускает
useEffectещё раз без перерисовки компонента, поэтому эффект всё ещё считает, чтоpostStatusравен'idle', и диспатчитfetchPostsвторой раз -
Оба thunks завершают получение своих данных и диспетчеризуют действие
fetchPosts.fulfilled; в результате редьюсерfulfilledзапускается дважды, что приводит к дублированию постов в состоянии
Как это можно исправить?
Один вариант — удалить тег <StrictMode> из нашего приложения. Но команда React рекомендует его использовать, и он действительно помогает выявлять другие проблемы.
Мы могли бы написать сложную логику с хуком useRef, чтобы отслеживать, действительно ли компонент рендерится впервые, и использовать это для однократного вызова fetchPosts. Но это выглядит некрасиво.
Последний вариант — использовать фактическое значение state.posts.status из Redux-состояния, чтобы проверить, выполняется ли уже запрос, и отменить thunk в таком случае. К счастью, createAsyncThunk предоставляет нам такую возможность.
Проверка условий для асинхронных thunk
createAsyncThunk принимает опциональный колбэк condition, который мы можем использовать для этой проверки. Если он предоставлен, то выполняется в начале вызова thunk и отменяет весь thunk, если condition возвращает false.
В нашем случае мы хотим избежать выполнения thunk, если поле state.posts.status не равно 'idle'. У нас уже есть селектор selectPostsStatus, который можно использовать, поэтому добавим опцию condition и проверим значение:
export const fetchPosts = createAppAsyncThunk(
'posts/fetchPosts',
async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
},
{
condition(arg, thunkApi) {
const postsStatus = selectPostsStatus(thunkApi.getState())
if (postsStatus !== 'idle') {
return false
}
}
}
)
Теперь при перезагрузке страницы в компоненте <PostsList> мы должны видеть только один набор постов без дубликатов, а в Redux DevTools — только один набор диспатченных действий.
Не обязательно добавлять condition ко всем thunk, но иногда это полезно для гарантии, что одновременно выполняется только один запрос.
Обратите внимание, что RTK Query берёт это на себя! Он дедуплицирует запросы во всех компонентах, поэтому каждый запрос выполняется только один раз, и вам не нужно беспокоиться об этом.
Отображение состояния загрузки
Наш компонент <PostsList> уже проверяет обновления постов в Redux-хранилище и перерисовывается при их изменении. При обновлении страницы мы должны видеть случайный набор постов из нашего фейкового API. Однако возникает задержка — сначала <PostsList> пуст, и только через несколько секунд отображаются посты.
Настоящий API-вызов обычно требует времени для получения ответа, поэтому разумно показывать индикатор загрузки (например, "Загрузка..."), чтобы пользователь знал об ожидании данных.
Мы можем обновить <PostsList>, чтобы отображать разный UI в зависимости от значения state.posts.status: спиннер при загрузке, сообщение об ошибке при сбое или сам список постов при наличии данных.
Попутно стоит выделить компонент <PostExcerpt>, инкапсулирующий логику рендеринга одного элемента списка.
Результат может выглядеть так:
import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { Spinner } from '@/components/Spinner'
import { TimeAgo } from '@/components/TimeAgo'
import { PostAuthor } from './PostAuthor'
import { ReactionButtons } from './ReactionButtons'
import {
Post,
selectAllPosts,
selectPostsError,
fetchPosts
} from './postsSlice'
interface PostExcerptProps {
post: Post
}
function PostExcerpt({ post }: PostExcerptProps) {
return (
<article className="post-excerpt" key={post.id}>
<h3>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>
<ReactionButtons post={post} />
</article>
)
}
export const PostsList = () => {
const dispatch = useAppDispatch()
const posts = useAppSelector(selectAllPosts)
const postStatus = useAppSelector(selectPostsStatus)
const postsError = useAppSelector(selectPostsError)
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
let content: React.ReactNode
if (postStatus === 'pending') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
// Sort posts in reverse chronological order by datetime string
const orderedPosts = posts
.slice()
.sort((a, b) => b.date.localeCompare(a.date))
content = orderedPosts.map(post => (
<PostExcerpt key={post.id} post={post} />
))
} else if (postStatus === 'rejected') {
content = <div>{postsError}</div>
}
return (
<section className="posts-list">
<h2>Posts</h2>
{content}
</section>
)
}
Вы могли заметить, что API-запросы выполняются довольно долго, а индикатор загрузки отображается несколько секунд. Наш mock-сервер специально добавляет 2-секундную задержку для всех ответов, чтобы визуализировать состояния загрузки. Если хотите изменить это поведение, откройте api/server.ts и измените строку:
// Add an extra delay to all endpoints, so loading spinners show up.
const ARTIFICIAL_DELAY_MS = 2000
Можете включать и отключать эту настройку по мере необходимости для ускорения запросов.
Опционально: Определение санков внутри createSlice
Сейчас наш санк fetchPosts определён в файле postsSlice.ts, но вне вызова createSlice().
Существует опциональный способ определения санков внутри createSlice, требующий изменения объявления поля reducers. Подробности в документации:
Defining Thunks in createSlice
We've seen that the standard way to write the createSlice.reducers field is as an object, where the keys become the action names, and the values are reducers. We also saw that the values can be an object with the {reducer, prepare} functions for creating an action object with the values we want.
Alternately, the reducers field can be a callback function that receives a create object. This is somewhat similar to what we saw with extraReducers, but with a different set of methods for creating reducers and actions:
create.reducer<PayloadType>(caseReducer): defines a case reducercreate.preparedReducer(prepare, caseReducer): defines a reducer with a prepare callback
Then, return an object like before with the reducer names as the fields, but call the create methods to make each reducer. Here's what the postsSlice would look like converted to this syntax:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: create => {
return {
postAdded: create.preparedReducer(
(title: string, content: string, userId: string) => {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId,
reactions: initialReactions
}
}
},
(state, action) => {
state.posts.push(action.payload)
}
),
postUpdated: create.reducer<PostUpdate>((state, action) => {
const { id, title, content } = action.payload
const existingPost = state.posts.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}),
reactionAdded: create.reducer<{ postId: string; reaction: ReactionName }>(
(state, action) => {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
)
}
},
extraReducers: builder => {
// same as before
}
})
Writing reducers as a callback opens the door for extending the capabilities of createSlice. In particular, it's possible to make a special version of createSlice that has the ability to use createAsyncThunk baked in.
First, import buildCreateSlice and asyncThunkCreator, then call buildCreateSlice like this:
import { buildCreateSlice, asyncThunkCreator } from '@reduxjs/toolkit'
export const createAppSlice = buildCreateSlice({
creators: { asyncThunk: asyncThunkCreator }
})
That gives you a version of createSlice with the ability to write thunks inside.
Finally, we can use that createAppSlice method to define our postsSlice with the fetchPosts thunk inside. When we do that, a couple other things change:
- We can't pass in the
RootStategeneric directly, so we have to dogetState() as RootStateto cast it - We can pass in all of the reducers that handle the thunk actions as part of the options to
create.asyncThunk(), and remove those from theextraReducersfield:
const postsSlice = createAppSlice({
name: 'posts',
initialState,
reducers: create => {
return {
// omit the other reducers
fetchPosts: create.asyncThunk(
// Payload creator function to fetch the data
async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
},
{
// Options for `createAsyncThunk`
options: {
condition(arg, thunkApi) {
const { posts } = thunkApi.getState() as RootState
if (posts.status !== 'idle') {
return false
}
}
},
// The case reducers to handle the dispatched actions.
// Each of these is optional, but must use these names.
pending: (state, action) => {
state.status = 'pending'
},
fulfilled: (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts.push(...action.payload)
},
rejected: (state, action) => {
state.status = 'rejected'
state.error = action.error.message ?? 'Unknown Error'
}
}
)
}
},
extraReducers: builder => {
builder.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
// The thunk handlers have been removed here
}
})
Remember, the create callback syntax is optional! The only time you have to use it is if you really want to write thunks inside of createSlice. That said, it does remove the need to use the PayloadAction type, and cuts down on extraReducers as well.
Загрузка пользователей
Теперь мы получаем и отображаем список постов, но есть проблема: все авторы отображаются как "Неизвестный":

Это происходит потому, что fake-API генерирует случайных пользователей при каждой перезагрузке. Нам нужно обновить users-слайс для загрузки этих пользователей при старте приложения.
Как и ранее, создадим асинхронный санк для получения пользователей из API, затем обработаем действие fulfilled в поле extraReducers. Пока пропустим обработку состояния загрузки:
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
interface User {
id: string
name: string
}
export const fetchUsers = createAppAsyncThunk('users/fetchUsers', async () => {
const response = await client.get<User[]>('/fakeApi/users')
return response.data
})
const initialState: User[] = []
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
return action.payload
})
}
})
export default usersSlice.reducer
// omit selectors
Вы могли заметить, что в этом редьюсере мы не используем переменную state. Вместо этого возвращаем action.payload напрямую. Immer позволяет обновлять состояние двумя способами: мутировать существующее значение или возвращать новый результат. При возврате нового значения оно полностью заменяет текущее состояние.
Начальное состояние было пустым массивом, и мы могли сделать state.push(...action.payload) для мутации. Но в данном случае нам нужно заменить список пользователей данными с сервера, что исключает случайное дублирование.
Подробнее об обновлении состояния с Immer: "Написание редьюсеров с Immer".
Нам нужно загрузить пользователей только один раз при старте приложения. Сделаем это в main.tsx, напрямую диспатча санк fetchUsers при наличии store:
// omit other imports
import store from './app/store'
import { fetchUsers } from './features/users/usersSlice'
import { worker } from './api/server'
async function start() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })
store.dispatch(fetchUsers())
const root = createRoot(document.getElementById('root')!)
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
}
start()
Это валидный способ начать загрузку данных до рендеринга React-компонентов, что ускоряет доступность данных. (Аналогичный подход используется в загрузчиках данных React Router).
Теперь в постах должны отображаться имена авторов, а в выпадающем списке "Автор" компонента <AddPostForm> появится этот же список пользователей.
Добавление новых постов
Нам остался ещё один шаг в этом разделе. Когда мы добавляем новый пост через <AddPostForm>, он пока сохраняется только в Redux-хранилище внутри нашего приложения. Нам нужно сделать реальный API-вызов, который создаст новую запись на нашем фейковом API-сервере, чтобы пост "сохранился". (Поскольку это фейковый API, новый пост не сохранится при перезагрузке страницы, но с реальным бэкендом он был бы доступен при следующей загрузке.)
Отправка данных с помощью санков
Мы можем использовать createAsyncThunk не только для получения данных, но и для их отправки. Создадим санк, который принимает значения из нашей <AddPostForm> как аргумент и выполняет HTTP POST запрос к фейковому API для сохранения данных.
В процессе мы изменим работу с новым постом в наших редюсерах. Сейчас наш postsSlice создаёт объект нового поста в prepare-колбэке для postAdded и генерирует уникальный ID. В большинстве приложений, сохраняющих данные на сервере, генерацией уникальных ID и заполнением дополнительных полей занимается сервер, обычно возвращая полные данные в ответе. Поэтому мы можем отправить тело запроса вида { title, content, user: userId }, а затем взнуть готовый объект поста из ответа сервера и добавить его в состояние postsSlice. Также выделим тип NewPost для представления объекта, передаваемого в санк.
type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>
type NewPost = Pick<Post, 'title' | 'content' | 'user'>
export const addNewPost = createAppAsyncThunk(
'posts/addNewPost',
// The payload creator receives the partial `{title, content, user}` object
async (initialPost: NewPost) => {
// We send the initial data to the fake API server
const response = await client.post<Post>('/fakeApi/posts', initialPost)
// The response includes the complete post object, including unique ID
return response.data
}
)
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// The existing `postAdded` reducer and prepare callback were deleted
reactionAdded(state, action) {}, // omit logic
postUpdated(state, action) {} // omit logic
},
extraReducers(builder) {
builder
// omit the cases for `fetchPosts` and `userLoggedOut`
.addCase(addNewPost.fulfilled, (state, action) => {
// We can directly add the new post object to our posts array
state.posts.push(action.payload)
})
}
})
// Remove `postAdded`
export const { postUpdated, reactionAdded } = postsSlice.actions
Проверка результатов санков в компонентах
Наконец, обновим <AddPostForm> для диспатча санка addNewPost вместо старого экшена postAdded. Поскольку это ещё один API-вызов к серверу, он займёт время и может завершиться ошибкой. Санк addNewPost() автоматически отправит экшены pending/fulfilled/rejected в Redux-хранилище, которые мы уже обрабатываем.
Мы могли бы отслеживать статус запроса в postsSlice, используя второй объединённый тип для состояния загрузки. Но в этом примере ограничимся отслеживанием состояния загрузки в компоненте, чтобы показать альтернативные возможности.
Хорошо бы хотя бы отключать кнопку "Сохранить пост" во время запроса, чтобы пользователь случайно не отправил пост дважды. Если запрос провалится, мы также можем показать сообщение об ошибке в форме или просто вывести его в консоль.
Наша компонентная логика может дождаться завершения асинхронного санка и проверить результат:
import React, { useState } from 'react'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectCurrentUsername } from '@/features/auth/authSlice'
import { addNewPost } from './postsSlice'
// omit field types
export const AddPostForm = () => {
const [addRequestStatus, setAddRequestStatus] = useState<'idle' | 'pending'>(
'idle'
)
const dispatch = useAppDispatch()
const userId = useAppSelector(selectCurrentUsername)!
const handleSubmit = async (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
const form = e.currentTarget
try {
setAddRequestStatus('pending')
await dispatch(addNewPost({ title, content, user: userId })).unwrap()
form.reset()
} catch (err) {
console.error('Failed to save the post: ', err)
} finally {
setAddRequestStatus('idle')
}
}
// omit rendering logic
}
Мы можем добавить статус загрузки через хук React useState, аналогично тому, как мы отслеживаем состояние загрузки в postsSlice для получения постов. В данном случае нам нужно знать только идёт ли запрос.
Когда мы вызываем dispatch(addNewPost()), асинхронный санк возвращает Promise из dispatch. Мы можем использовать await для этого промиса, чтобы узнать когда запрос завершился. Но пока мы не знаем, был ли он успешным.
createAsyncThunk обрабатывает ошибки внутри, поэтому мы не видим сообщений о "rejected Promises" в логах. Затем он возвращает финальный экшен: fulfilled при успехе или rejected при ошибке. Это значит, что await dispatch(someAsyncThunk()) всегда "успешен", а результатом является сам объект экшена.
Однако часто требуется логика, реагирующая на успех или провал реального запроса. Redux Toolkit добавляет к возвращаемому Promise метод .unwrap(), который возвращает новый Promise либо с действительным значением action.payload из fulfilled экшена, либо выбрасывает ошибку для rejected экшена. Это позволяет обрабатывать успех и ошибку в компоненте через стандартный try/catch. Так мы очистим поля ввода после успешного создания поста и выведем ошибку в консоль при сбое.
Если вы хотите увидеть, что происходит при неудачном вызове API addNewPost, попробуйте создать новую запись, где поле «Content» содержит только слово «error» (без кавычек). Сервер распознает это и вернёт ответ с ошибкой, поэтому в консоли должно появиться соответствующее сообщение.
Итоги изученного
Асинхронная логика и получение данных — это всегда сложная тема. Как вы уже видели, Redux Toolkit включает инструменты для автоматизации типичных шаблонов работы с данными в Redux.
Вот как теперь выглядит наше приложение, получающее данные из тестового API:
Напомним, что мы рассмотрели в этом разделе:
- Redux использует плагины middleware для поддержки асинхронной логики
- Стандартное решение —
redux-thunk, входящее в Redux Toolkit - Thunk-функции получают аргументы
dispatchиgetStateдля использования в асинхронных операциях
- Стандартное решение —
- Можно диспатчить дополнительные экшены для отслеживания состояния загрузки
- Типичный паттерн: диспатч экшена «pending» до вызова, затем «success» с данными или «failure» с ошибкой
- Состояние загрузки обычно хранится как объединение литералов:
'idle' | 'pending' | 'succeeded' | 'rejected'
- Redux Toolkit предоставляет API
createAsyncThunkдля автоматизации этих экшеновcreateAsyncThunkпринимает callback «payload creator», возвращающий Promise, и автоматически генерирует типы экшеновpending/fulfilled/rejected- Сгенерированные создатели экшенов (например
fetchPosts) диспатчат экшены на основе возвращаемого Promise - Эти типы экшенов можно обрабатывать в
createSliceчерез полеextraReducers, обновляя состояние в редюсерах - Опция
conditionвcreateAsyncThunkпозволяет отменять запросы на основе состояния Redux - Thunk'и возвращают промисы. Для
createAsyncThunkможно использоватьawait dispatch(someThunk()).unwrap()для обработки успеха/ошибки на уровне компонента
Что дальше?
У нас остался ещё один набор тем, охватывающих основные API Redux Toolkit и паттерны использования. В Части 6: Производительность и нормализация данных мы рассмотрим влияние Redux на производительность React и способы оптимизации приложения.