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

Redux Fundamentals, Часть 6: Асинхронная логика и получение данных

Неофициальный Бета-перевод

Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →

Что вы изучите
  • Как работает поток данных Redux с асинхронными данными
  • Как использовать middleware Redux для асинхронной логики
  • Шаблоны обработки состояния асинхронных запросов
Предварительные требования
  • Умение работать с HTTP-запросами для получения и обновления данных с сервера
  • Понимание асинхронной логики в JS, включая промисы (Promises)

Введение

В Части 5: UI и React мы рассмотрели, как использовать библиотеку React-Redux для взаимодействия React-компонентов с хранилищем Redux, включая вызов useSelector для чтения состояния Redux, вызов useDispatch для доступа к функции dispatch и обёртывание приложения в компонент <Provider> для предоставления хукам доступа к хранилищу.

До сих пор все данные, с которыми мы работали, находились непосредственно в нашем клиентском приложении React+Redux. Однако большинству реальных приложений необходимо работать с данными с сервера, выполняя HTTP-вызовы API для получения и сохранения элементов.

В этом разделе мы обновим наше приложение для работы с задачами (todo), чтобы оно получало задачи из API и добавляло новые задачи, сохраняя их через API.

Неофициальный Бета-перевод

Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →

Внимание!

Обратите внимание, что в этом руководстве намеренно используются устаревшие шаблоны логики Redux, требующие больше кода, чем "современные Redux" шаблоны с Redux Toolkit, которые мы рекомендуем как правильный подход для создания приложений на Redux сегодня. Это сделано для объяснения принципов и концепций Redux. Данный материал не предназначен для использования в production-проектах.

Чтобы изучить "современный Redux" с Redux Toolkit, перейдите по ссылкам:

Совет

Redux Toolkit включает API для получения данных и кэширования RTK Query. RTK Query — это специализированное решение для получения данных и кэширования в приложениях Redux, которое позволяет полностью избежать написания санков (thunks) или редюсеров для управления получением данных. Мы специально преподаём RTK Query как основной подход к получению данных, и он построен на тех же шаблонах, что показаны на этой странице.

Узнайте, как использовать RTK Query для получения данных, в разделе Redux Essentials, Часть 7: Основы RTK Query.

Пример REST API и клиента

Чтобы пример проекта был изолированным, но реалистичным, начальная настройка проекта уже включает фейковое in-memory REST API для наших данных (настроенное с помощью инструмента Mirage.js для мокирования API). API использует /fakeApi как базовый URL для эндпоинтов и поддерживает стандартные HTTP-методы GET/POST/PUT/DELETE для /fakeApi/todos. Оно определено в src/api/server.js.

Проект также содержит небольшой объект HTTP API-клиента, предоставляющий методы client.get() и client.post(), аналогично популярным HTTP-библиотекам вроде axios. Он определён в src/api/client.js.

В этом разделе мы будем использовать объект client для выполнения HTTP-вызовов к нашему фейковому in-memory REST API.

Middleware Redux и побочные эффекты

Само по себе хранилище Redux ничего не знает об асинхронной логике. Оно умеет только синхронно диспетчеризовать действия, обновлять состояние путём вызова корневого редюсера и уведомлять UI об изменениях. Любая асинхронность должна происходить вне хранилища.

Ранее мы говорили, что редюсеры Redux никогда не должны содержать "побочных эффектов". "Побочный эффект" — это любое изменение состояния или поведения, которое можно наблюдать вне возвращаемого функцией значения. Распространённые примеры побочных эффектов:

  • Вывод значения в консоль

  • Сохранение файла

  • Установка асинхронного таймера

  • Выполнение HTTP-запроса

  • Изменение состояния вне функции или мутация аргументов функции

  • Генерация случайных чисел или уникальных ID (например, Math.random() или Date.now())

Однако любому реальному приложению где-то нужно выполнять подобные операции. И если мы не можем размещать побочные эффекты в редюсерах, где можно их размещать?

Middleware Redux были разработаны для реализации логики с побочными эффектами.

Как мы говорили в Части 4, Redux middleware может выполнять любые действия при получении диспетчеризованного экшена: логировать данные, модифицировать экшен, задерживать его выполнение, делать асинхронные вызовы и многое другое. Кроме того, поскольку middleware формируют конвейер вокруг реальной функции store.dispatch, это также означает, что мы можем передавать в dispatch нечто, не являющееся обычным объектом экшена — при условии, что middleware перехватит это значение и не допустит его попадания в редюсеры.

Middleware также имеют доступ к dispatch и getState. Это означает, что вы можете написать асинхронную логику внутри middleware и сохранить возможность взаимодействия с Redux-хранилищем путём диспетчеризации экшенов.

Использование Middleware для асинхронной логики

Рассмотрим несколько примеров того, как middleware позволяет реализовать асинхронную логику, взаимодействующую с Redux-хранилищем.

Один из вариантов — написание middleware, который ищет определённые типы экшенов и выполняет асинхронную логику при их обнаружении, как в этих примерах:

import { client } from '../api/client'

const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
// Delay this action by one second
next(action)
}, 1000)
return
}

return next(action)
}

const fetchTodosMiddleware = storeAPI => next => action => {
if (action.type === 'todos/fetchTodos') {
// Make an API call to fetch todos from the server
client.get('todos').then(todos => {
// Dispatch an action with the todos we received
storeAPI.dispatch({ type: 'todos/todosLoaded', payload: todos })
})
}

return next(action)
}
Информация

Подробнее о том, почему и как Redux использует middleware для асинхронной логики, см. в ответах создателя Redux Дэна Абрамова на StackOverflow:

Создание Middleware для асинхронных функций

Оба примера middleware из предыдущего раздела были узкоспециализированными. Было бы удобно иметь возможность заранее писать любую асинхронную логику отдельно от самого middleware, сохраняя доступ к dispatch и getState для взаимодействия с хранилищем.

Что если создать middleware, позволяющий передавать в dispatch функцию вместо объекта экшена? Наш middleware мог бы проверять, является ли "экшен" функцией, и если да — вызывать её. Это позволило бы выносить асинхронную логику в отдельные функции вне определения middleware.

Вот как может выглядеть такой middleware:

Example async function middleware
const asyncFunctionMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
return action(storeAPI.dispatch, storeAPI.getState)
}

// Otherwise, it's a normal action - send it onwards
return next(action)
}

Использовать этот middleware можно так:

const middlewareEnhancer = applyMiddleware(asyncFunctionMiddleware)
const store = createStore(rootReducer, middlewareEnhancer)

// Write a function that has `dispatch` and `getState` as arguments
const fetchSomeData = (dispatch, getState) => {
// Make an async HTTP request
client.get('todos').then(todos => {
// Dispatch an action with the todos we received
dispatch({ type: 'todos/todosLoaded', payload: todos })
// Check the updated store state after dispatching
const allTodos = getState().todos
console.log('Number of todos after loading: ', allTodos.length)
})
}

// Pass the _function_ we wrote to `dispatch`
store.dispatch(fetchSomeData)
// logs: 'Number of todos after loading: ###'

Обратите внимание: этот "middleware для асинхронных функций" позволяет передавать в dispatch функцию! Внутри функции мы можем реализовать асинхронную логику (например, HTTP-запрос), а затем диспетчеризовать обычный объект экшена при завершении запроса.

Поток данных в Redux с асинхронными операциями

Как middleware и асинхронная логика влияют на общий поток данных в Redux-приложении?

Как и с обычным экшеном, сначала обрабатываем пользовательское событие (например, клик по кнопке). Затем вызываем dispatch(), передавая нечто — будь то объект экшена, функция или другое значение, которое может обработать middleware.

Когда переданное значение достигает middleware, тот может выполнить асинхронный вызов и диспетчеризовать реальный объект экшена по завершении операции.

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

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

Использование Redux Thunk Middleware

Оказывается, в Redux уже есть официальная версия такого "middleware для асинхронных функций" — Redux Thunk Middleware. Thunk middleware позволяет писать функции, принимающие dispatch и getState в качестве аргументов. Thunk-функции могут содержать любую асинхронную логику и при необходимости диспетчеризовать экшены или читать состояние хранилища.

Написание асинхронной логики в виде функций thunk позволяет повторно использовать эту логику без необходимости заранее знать, какое хранилище Redux мы используем.

Информация

Слово "thunk" — это программистский термин, означающий "фрагмент кода, выполняющий отложенную работу". Подробнее об использовании thunk можно прочитать на странице руководства:

а также в этих статьях:

Настройка хранилища

Промежуточное ПО Redux thunk доступно в NPM как пакет redux-thunk. Нам нужно установить этот пакет для использования в нашем приложении:

npm install redux-thunk

После установки мы можем обновить хранилище Redux в нашем приложении для задач, чтобы использовать это промежуточное ПО:

src/store.js
import { createStore, applyMiddleware } from 'redux'
import { thunk } from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducer'

const composedEnhancer = composeWithDevTools(applyMiddleware(thunk))

// The store now has the ability to accept thunk functions in `dispatch`
const store = createStore(rootReducer, composedEnhancer)
export default store

Получение задач с сервера

Сейчас наши задачи существуют только в браузере клиента. Нам нужен способ загружать список задач с сервера при запуске приложения.

Мы начнём с написания функции thunk, которая выполняет HTTP-запрос к нашему эндпоинту /fakeApi/todos для получения массива объектов задач, а затем диспетчеризует действие, содержащее этот массив в качестве полезной нагрузки. Поскольку это относится к функциональности задач в целом, мы напишем функцию thunk в файле todosSlice.js:

src/features/todos/todosSlice.js
import { client } from '../../api/client'

const initialState = []

export default function todosReducer(state = initialState, action) {
// omit reducer logic
}

// Thunk function
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}

Мы хотим выполнить этот вызов API только один раз, при первом запуске приложения. Есть несколько мест, куда мы могли бы это поместить:

  • В компоненте <App>, в хуке useEffect

  • В компоненте <TodoList>, в хуке useEffect

  • Непосредственно в файле index.js, сразу после импорта хранилища

Пока попробуем поместить это непосредственно в index.js:

src/index.js
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import './index.css'
import App from './App'

import './api/server'

import store from './store'
import { fetchTodos } from './features/todos/todosSlice'

store.dispatch(fetchTodos)

const root = createRoot(document.getElementById('root'))

root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)

Если мы перезагрузим страницу, видимых изменений в UI не будет. Однако, если мы откроем расширение Redux DevTools, то должны увидеть, что действие 'todos/todosLoaded' было диспетчеризовано и оно содержит некоторые объекты задач, сгенерированные нашим фейковым серверным API:

Devtools - содержимое действия todosLoaded

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

Добавим обработку этого действия в редюсер, чтобы загрузить данные в хранилище. Поскольку мы получаем данные с сервера, мы хотим полностью заменить существующие задачи, поэтому мы можем вернуть массив action.payload, чтобы он стал новым значением state для задач:

src/features/todos/todosSlice.js
import { client } from '../../api/client'

const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other reducer cases
case 'todos/todosLoaded': {
// Replace the existing state entirely by returning the new value
return action.payload
}
default:
return state
}
}

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}

Поскольку диспетчеризация действия немедленно обновляет хранилище, мы также можем вызвать getState в thunk, чтобы прочитать обновлённое состояние после диспетчеризации. Например, мы можем вывести в консоль общее количество задач до и после диспетчеризации действия 'todos/todosLoaded':

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')

const stateBefore = getState()
console.log('Todos before dispatch: ', stateBefore.todos.length)

dispatch({ type: 'todos/todosLoaded', payload: response.todos })

const stateAfter = getState()
console.log('Todos after dispatch: ', stateAfter.todos.length)
}

Сохранение задач

Нам также необходимо обновлять сервер при попытке создать новую задачу. Вместо немедленной диспетчеризации действия 'todos/todoAdded', мы должны выполнить вызов API на сервер с исходными данными, дождаться, пока сервер вернёт копию только что сохранённой задачи, и затем диспетчеризовать действие с этой задачей.

Однако, если мы попытаемся написать эту логику как функцию thunk, мы столкнёмся с проблемой: поскольку мы пишем thunk как отдельную функцию в файле todosSlice.js, код, выполняющий вызов API, не будет знать, каким должен быть текст новой задачи:

src/features/todos/todosSlice.js
async function saveNewTodo(dispatch, getState) {
// ❌ We need to have the text of the new todo, but where is it coming from?
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}

Нам нужен способ написать функцию, которая принимает text как параметр, но затем создаёт саму thunk-функцию, чтобы использовать значение text для выполнения API-вызова. Наша внешняя функция должна возвращать thunk-функцию, чтобы мы могли передать её в dispatch в нашем компоненте.

src/features/todos/todosSlice.js
// Write a synchronous outer function that receives the `text` parameter:
export function saveNewTodo(text) {
// And then creates and returns the async thunk function:
return async function saveNewTodoThunk(dispatch, getState) {
// ✅ Now we can use the text value and send it to the server
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}
}

Теперь мы можем использовать это в компоненте <Header>:

src/features/header/Header.js
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'

import { saveNewTodo } from '../todos/todosSlice'

const Header = () => {
const [text, setText] = useState('')
const dispatch = useDispatch()

const handleChange = e => setText(e.target.value)

const handleKeyDown = e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create the thunk function with the text the user wrote
const saveNewTodoThunk = saveNewTodo(trimmedText)
// Then dispatch the thunk function itself
dispatch(saveNewTodoThunk)
setText('')
}
}

// omit rendering output
}

Поскольку мы знаем, что сразу передадим thunk-функцию в dispatch в компоненте, мы можем пропустить создание временной переменной. Вместо этого мы можем вызвать saveNewTodo(text) и передать результирующую thunk-функцию напрямую в dispatch:

src/features/header/Header.js
const handleKeyDown = e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create the thunk function and immediately dispatch it
dispatch(saveNewTodo(trimmedText))
setText('')
}
}

Теперь компонент даже не знает, что он диспетчеризует thunk-функцию — функция saveNewTodo инкапсулирует реальную логику. Компонент <Header> знает только, что ему нужно диспетчеризовать какое-то значение при нажатии пользователем Enter.

Этот шаблон написания функции для подготовки чего-либо, что будет передано в dispatch, называется "action creator", и мы подробнее поговорим об этом в следующем разделе.

Теперь мы видим, что диспетчеризуется обновлённое действие 'todos/todoAdded':

Devtools - содержимое асинхронного todoAdded действия

Последнее, что нам нужно изменить, — это обновить наш редюсер todos. Когда мы делаем POST-запрос к /fakeApi/todos, сервер вернёт совершенно новый объект todo (включая новое значение ID). Это означает, что нашему редюсеру не нужно вычислять новый ID или заполнять другие поля — ему достаточно создать новый массив state, включающий новый элемент todo:

src/features/todos/todosSlice.js
const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
// Return a new todos state array with the new todo item at the end
return [...state, action.payload]
}
// omit other cases
default:
return state
}
}

И теперь добавление нового todo будет работать корректно:

Devtools - diff состояния после асинхронного todoAdded

Совет

Thunk-функции можно использовать как для асинхронной, так и для синхронной логики. Thunks предоставляют способ написания любой повторно используемой логики, которой нужен доступ к dispatch и getState.

Итоги изученного

Мы успешно обновили наше приложение todo: теперь мы можем загружать список задач и сохранять новые задачи, используя "thunk"-функции для выполнения HTTP-запросов к нашему фейковому серверному API.

В процессе мы увидели, как промежуточное ПО Redux позволяет выполнять асинхронные вызовы и взаимодействовать с хранилищем, диспетчеризуя действия после завершения асинхронных операций.

Вот как выглядит текущее приложение:

Ключевые моменты
  • Промежуточное ПО Redux создано для реализации логики с побочными эффектами
    • "Побочные эффекты" — это код, изменяющий состояние/поведение вне функции, например HTTP-запросы, модификация аргументов или генерация случайных значений
  • ПО добавляет дополнительный шаг в стандартный поток данных Redux
    • Может перехватывать значения, передаваемые в dispatch
    • Имеет доступ к dispatch и getState, позволяя диспетчеризовать дополнительные действия в асинхронной логике
  • ПО "Thunk" позволяет передавать функции в dispatch
    • "Thunk"-функции позволяют заранее писать асинхронную логику без знания конкретного хранилища Redux
    • Thunk-функция получает dispatch и getState как аргументы и может диспетчеризовать действия типа "эти данные получены из ответа API"

Что дальше?

Мы рассмотрели все основные аспекты использования Redux! Теперь вы знаете как:

  • Писать редюсеры, обновляющие состояние на основе диспетчеризуемых действий

  • Создавать и настраивать хранилище Redux с редюсером, расширениями и промежуточным ПО

  • Использовать промежуточное ПО для написания асинхронной логики, диспетчеризующей действия

В Части 7: Стандартные шаблоны Redux мы рассмотрим несколько паттернов кода, обычно используемых в реальных приложениях Redux, чтобы сделать наш код более согласованным и масштабируемым по мере роста приложения.