Перейти к основному содержимому

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 и хранилище Redux

Подробнее о кастомизации хранилища через middleware:

Middleware и поток данных в Redux

Ранее мы рассматривали синхронный поток данных в Redux.

Middleware изменяют этот поток, добавляя дополнительный этап перед dispatch. Это позволяет middleware выполнять логику (например HTTP-запросы) перед диспетчеризацией действий. Асинхронный поток данных выглядит так:

Диаграмма асинхронного потока данных в Redux

Thunks и асинхронная логика

Для Redux существует множество асинхронных middleware с разным синтаксисом. Наиболее популярный — redux-thunk, позволяющий писать простые функции с прямой асинхронной логикой. Функция configureStore из Redux Toolkit по умолчанию автоматически подключает thunk-middleware, и мы рекомендуем использовать thunks как стандартный подход для асинхронной логики в Redux.

Что такое "Thunk"?

Термин "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 выглядит так:

createAsyncThunk example
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 и использовать его:

app/store.ts
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-функций:

Example typed 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 и экспортируем оттуда:

app/withTypes.ts
import { createAsyncThunk } from '@reduxjs/toolkit'

import type { RootState, AppDispatch } from './store'

export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState
dispatch: AppDispatch
}>()
Типизация thunk-функций

Подробнее о типизации 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}. Также удалим старые примеры постов из начального состояния и добавим несколько новых селекторов для полей загрузки и ошибок:

features/posts/postsSlice.ts
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'.

features/posts/postsSlice.ts
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':

createAsyncThunk: действие pending для постов

Мы можем обрабатывать это действие в нашем редюсере и помечать статус запроса как 'pending'.

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

createAsyncThunk: действие posts/pending

Редьюсеры и действия загрузки

Далее нам нужно обработать оба этих действия в наших редьюсерах. Это требует более глубокого понимания 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 для обработки:

features/posts/postsSlice.ts
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' (ещё не начат).

features/posts/PostsList.tsx
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':

Дубликаты действий fetchPosts

Почему так? Разве мы не добавили проверку postStatus === 'idle'? Разве этого недостаточно для гарантии однократной отправки thunk?

Ну, да... и нет :)

Сама логика в useEffect здесь верна. Проблема в том, что сейчас мы используем development-сборку нашего приложения, а в режиме разработки React запускает все хуки useEffect дважды при монтировании внутри компонента <StrictMode>, чтобы сделать определённые ошибки более заметными.

В данном случае произошло следующее:

  • Компонент <PostsList> смонтировался

  • Хук useEffect выполнился в первый раз. Значение postStatus равно 'idle', поэтому он диспатчит thunk fetchPosts.

  • 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>, инкапсулирующий логику рендеринга одного элемента списка.

Результат может выглядеть так:

features/posts/PostsList.tsx
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 и измените строку:

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 reducer
  • create.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 RootState generic directly, so we have to do getState() as RootState to 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 the extraReducers field:
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. Пока пропустим обработку состояния загрузки:

features/users/usersSlice.ts
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:

main.tsx
// 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 для представления объекта, передаваемого в санк.

features/posts/postsSlice.ts
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, используя второй объединённый тип для состояния загрузки. Но в этом примере ограничимся отслеживанием состояния загрузки в компоненте, чтобы показать альтернативные возможности.

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

Наша компонентная логика может дождаться завершения асинхронного санка и проверить результат:

features/posts/AddPostForm.tsx
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 и способы оптимизации приложения.