Написание логики с использованием Thunks
Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →
- Что такое "thunks" и зачем они используются для написания логики Redux
- Как работает middleware для thunks
- Техники написания синхронной и асинхронной логики в thunks
- Распространенные паттерны использования thunks
Обзор Thunks
Что такое "thunk"?
Термин "thunk" в программировании означает "фрагмент кода, выполняющий отложенную работу". Вместо немедленного выполнения логики мы можем написать тело функции или код, который выполнит работу позже.
В контексте Redux "thunks" — это подход к написанию функций с внутренней логикой, которая может взаимодействовать с методами dispatch и getState хранилища Redux.
Для использования thunks требуется добавить middleware redux-thunk в конфигурацию хранилища Redux.
Thunks являются стандартным подходом для написания асинхронной логики в Redux-приложениях и обычно используются для получения данных. Однако их можно применять для различных задач, и они могут содержать как синхронную, так и асинхронную логику.
Написание Thunks
Thunk-функция — это функция, принимающая два аргумента: метод dispatch хранилища Redux и метод getState. Такие функции не вызываются напрямую из кода приложения. Вместо этого они передаются в store.dispatch():
const thunkFunction = (dispatch, getState) => {
// logic here that can dispatch actions or read state
}
store.dispatch(thunkFunction)
Thunk-функция может содержать любую произвольную логику (синхронную или асинхронную) и вызывать dispatch или getState в любой момент.
Подобно тому, как в Redux обычно используются action creators для генерации action-объектов вместо их ручного создания, мы используем thunk action creators для генерации dispatch-функций. Thunk action creator — это функция, которая принимает аргументы и возвращает новую thunk-функцию. Thunk обычно "замыкает" аргументы, переданные в action creator, чтобы использовать их в своей логике:
// fetchTodoById is the "thunk action creator"
export function fetchTodoById(todoId) {
// fetchTodoByIdThunk is the "thunk function"
return async function fetchTodoByIdThunk(dispatch, getState) {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}
}
Thunk-функции и action creators могут быть написаны с использованием ключевого слова function или стрелочных функций — разницы здесь нет. Тот же thunk fetchTodoById может быть записан и со стрелочными функциями:
export const fetchTodoById = todoId => async dispatch => {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}
В обоих случаях thunk вызывается через вызов action creator'а, аналогично dispatch'у любого другого Redux-действия:
function TodoComponent({ todoId }) {
const dispatch = useDispatch()
const onFetchClicked = () => {
// Calls the thunk action creator, and passes the thunk function to dispatch
dispatch(fetchTodoById(todoId))
}
}
Зачем использовать Thunks?
Thunks позволяют выносить дополнительную логику, связанную с Redux, за пределы UI-слоя. Эта логика может включать побочные эффекты (например, асинхронные запросы или генерацию случайных значений), а также операции, требующие множественных dispatch'ей или доступа к состоянию хранилища.
Редюсеры Redux не должны содержать побочных эффектов, но реальным приложениям нужна логика с такими эффектами. Часть может находиться в компонентах, но другая часть должна быть вынесена за UI-слой. Thunks (и другие middleware Redux) предоставляют место для размещения таких эффектов.
Часто логика размещается прямо в компонентах — например, асинхронные запросы в обработчиках кликов или хуках useEffect с последующей обработкой результатов. Однако часто необходимо выносить максимум такой логики за UI-слой. Это улучшает тестируемость, позволяет сохранять UI-слой тонким и "презентационным", а также способствует повторному использованию кода.
По сути, thunk — это механизм, который позволяет написать код, взаимодействующий с Redux-хранилищем, заранее, без необходимости знать какое именно хранилище будет использоваться. Это делает логику независимой от конкретного экземпляра хранилища и обеспечивает её повторное использование.
Detailed Explanation: Thunks, Connect, and "Container Components"
Historically, another reason to use thunks was to help keep React components "unaware of Redux". The connect API allowed passing action creators and "binding" them to automatically dispatch actions when called. Since components typically did not have access to dispatch internally, passing thunks to connect made it possible for components to just call this.props.doSomething(), without needing to know if it was a callback from a parent, dispatching a plain Redux action, dispatching a thunk performing sync or async logic, or a mock function in a test.
With the arrival of the React-Redux hooks API, that situation has changed. The community has switched away from the "container/presentational" pattern in general, and components now have access to dispatch directly via the useDispatch hook. This does mean that it's possible to have more logic directly inside of a component, such as an async fetch + dispatch of the results. However, thunks have access to getState, which components do not, and there's still value in moving that logic outside of components.
Случаи применения thunk
Поскольку thunk — это универсальный инструмент, содержащий произвольную логику, их можно использовать для самых разных задач. Наиболее распространённые сценарии:
-
Вынос сложной логики из компонентов
-
Выполнение асинхронных запросов или другой асинхронной логики
-
Реализация логики, требующей последовательной или отложенной отправки нескольких действий
-
Логика, которой нужен доступ к
getStateдля принятия решений или включения других значений состояния в действие
Thunk — это "одноразовые" функции без жизненного цикла. Они не могут отслеживать другие отправленные действия. Поэтому их не следует использовать для инициализации постоянных соединений (например, websockets), и они не могут реагировать на другие действия.
Thunk лучше всего подходят для сложной синхронной логики и простых/умеренных асинхронных операций, например выполнения AJAX-запросов и отправки действий на основе их результатов.
Middleware Redux Thunk
Для отправки thunk-функций требуется, чтобы redux-thunk middleware была добавлена в Redux-хранилище при его настройке.
Добавление middleware
API Redux Toolkit configureStore автоматически добавляет thunk middleware при создании хранилища, поэтому обычно она доступна без дополнительной настройки.
Для ручного добавления thunk middleware передайте её в applyMiddleware() в процессе настройки.
Как работает middleware?
Для начала вспомним принцип работы middleware в Redux.
Все middleware Redux реализованы как три вложенные функции:
-
Внешняя функция получает объект API хранилища
{dispatch, getState} -
Средняя функция получает
next(следующую middleware в цепочке или самstore.dispatch) -
Внутренняя функция вызывается для каждого
action, проходящего через цепочку middleware
Middleware позволяют передавать в store.dispatch() значения, которые не являются объектами действий, если они перехватываются middleware и не доходят до редьюсеров.
Учитывая это, рассмотрим особенности thunk middleware.
Реализация thunk middleware занимает всего около 10 строк. Исходный код с комментариями:
// standard middleware definition, with 3 nested functions:
// 1) Accepts `{dispatch, getState}`
// 2) Accepts `next`
// 3) Accepts `action`
const thunkMiddleware =
({ dispatch, getState }) =>
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(dispatch, getState)
}
// Otherwise, it's a normal action - send it onwards
return next(action)
}
Другими словами:
-
При передаче функции в
dispatchthunk middleware распознаёт её, перехватывает и вызывает с аргументами(dispatch, getState) -
Обычные объекты действий (или другие значения) передаются следующей middleware в цепочке
Внедрение конфигурационных значений
Thunk middleware поддерживает кастомизацию: при настройке можно создать её экземпляр с "дополнительным аргументом", который будет передаваться третьим параметром в каждую thunk-функцию. Чаще всего это используется для внедрения API-сервиса, чтобы избежать жёсткой привязки к методам API:
import { withExtraArgument } from 'redux-thunk'
const serviceApi = createServiceApi('/some/url')
const thunkMiddlewareWithArg = withExtraArgument({ serviceApi })
configureStore из Redux Toolkit поддерживает эту возможность как часть кастомизации middleware в getDefaultMiddleware:
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
thunk: {
extraArgument: { serviceApi }
}
})
})
Может быть только одно дополнительное значение аргумента. Если требуется передать несколько значений, используйте объект, содержащий их.
Thunk-функция получит это дополнительное значение в качестве третьего аргумента:
export const fetchTodoById =
todoId => async (dispatch, getState, extraArgument) => {
// In this example, the extra arg is an object with an API service inside
const { serviceApi } = extraArgument
const response = await serviceApi.getTodo(todoId)
dispatch(todosLoaded(response.todos))
}
Шаблоны использования Thunk
Диспетчеризация действий
Thunks имеют доступ к методу dispatch. Это позволяет диспетчеризовать действия или даже другие thunks. Это полезно для последовательной диспетчеризации нескольких действий (хотя этот паттерн следует минимизировать) или организации сложной логики, требующей диспетчеризации в нескольких точках процесса.
// An example of a thunk dispatching other action creators,
// which may or may not be thunks themselves. No async code, just
// orchestration of higher-level synchronous logic.
function complexSynchronousThunk(someValue) {
return (dispatch, getState) => {
dispatch(someBasicActionCreator(someValue))
dispatch(someThunkActionCreator())
}
}
Доступ к состоянию
В отличие от компонентов, thunks также имеют доступ к getState. Этот метод можно вызывать в любой момент для получения текущего корневого состояния Redux. Это полезно для условной логики, основанной на текущем состоянии. Обычно используют функции-селекторы при чтении состояния внутри thunks вместо прямого доступа к вложенным полям состояния, но оба подхода допустимы.
const MAX_TODOS = 5
function addTodosIfAllowed(todoText) {
return (dispatch, getState) => {
const state = getState()
// Could also check `state.todos.length < MAX_TODOS`
if (selectCanAddNewTodo(state, MAX_TODOS)) {
dispatch(todoAdded(todoText))
}
}
}
Предпочтительно размещать максимум логики в редюсерах, но допустимо иметь дополнительную логику и внутри thunks.
Поскольку состояние обновляется синхронно сразу после обработки действия редюсерами, вы можете вызвать getState после диспетчеризации, чтобы получить обновлённое состояние.
function checkStateAfterDispatch() {
return (dispatch, getState) => {
const firstState = getState()
dispatch(firstAction())
const secondState = getState()
if (secondState.someField != firstState.someField) {
dispatch(secondAction())
}
}
}
Ещё одна причина для доступа к состоянию в thunk — дополнение действия дополнительной информацией. Иногда редюсеру слайса нужно прочитать значение, отсутствующее в его собственной части состояния. Возможное решение — диспетчеризовать thunk, извлечь нужные значения из состояния и затем диспетчеризовать обычное действие с дополнительной информацией.
// One solution to the "cross-slice state in reducers" problem:
// read the current state in a thunk, and include all the necessary
// data in the action
function crossSliceActionThunk() {
return (dispatch, getState) => {
const state = getState()
// Read both slices out of state
const { a, b } = state
// Include data from both slices in the action
dispatch(actionThatNeedsMoreData(a, b))
}
}
Асинхронная логика и побочные эффекты
Thunks могут содержать асинхронную логику и побочные эффекты, например обновление localStorage. Такая логика может использовать цепочки Promise, такие как someResponsePromise.then(), хотя для читаемости обычно предпочтительнее синтаксис async/await.
При выполнении асинхронных запросов стандартно диспетчеризовать действия до и после запроса, чтобы отслеживать состояние загрузки. Обычно диспетчеризуется действие "pending" перед запросом, а состояние загрузки помечается как "in progress". При успешном запросе диспетчеризуется действие "fulfilled" с данными результата, либо "rejected" с информацией об ошибке.
Обработка ошибок здесь сложнее, чем кажется. Если объединить resPromise.then(dispatchFulfilled).catch(dispatchRejected), можно случайно диспетчеризовать действие "rejected" при возникновении ошибки не в сети во время обработки "fulfilled". Лучше использовать второй аргумент .then(), чтобы обрабатывать только ошибки, связанные с самим запросом:
function fetchData(someValue) {
return (dispatch, getState) => {
dispatch(requestStarted())
myAjaxLib.post('/someEndpoint', { data: someValue }).then(
response => dispatch(requestSucceeded(response.data)),
error => dispatch(requestFailed(error.message))
)
}
}
С async/await это ещё сложнее из-за типичной организации логики try/catch. Чтобы гарантировать, что блок catch обрабатывает только сетевые ошибки, может потребоваться реорганизовать логику так, чтобы thunk завершался досрочно при ошибке, а действие "fulfilled" происходило только в конце:
function fetchData(someValue) {
return async (dispatch, getState) => {
dispatch(requestStarted())
// Have to declare the response variable outside the try block
let response
try {
response = await myAjaxLib.post('/someEndpoint', { data: someValue })
} catch (error) {
// Ensure we only catch network errors
dispatch(requestFailed(error.message))
// Bail out early on failure
return
}
// We now have the result and there's no error. Dispatch "fulfilled".
dispatch(requestSucceeded(response.data))
}
}
Отметим, что эта проблема не специфична для Redux или thunks — она может возникать даже при работе с состоянием компонентов React или любой другой логикой, требующей дополнительной обработки успешного результата.
Этот паттерн действительно неудобен для написания и чтения. В большинстве случаев можно использовать более типичный try/catch, где запрос и dispatch(requestSucceeded()) идут подряд. Но всё же полезно знать о потенциальной проблеме.
Возврат значений из Thunks
По умолчанию store.dispatch(action) возвращает сам объект действия. Промежуточное ПО (middleware) может переопределить возвращаемое значение, передаваемое обратно из dispatch, и подставить любой другой результат. Например, middleware может всегда возвращать 42:
const return42Middleware = storeAPI => next => action => {
const originalReturnValue = next(action)
return 42
}
// later
const result = dispatch(anyAction())
console.log(result) // 42
Промежуточное ПО для thunks делает именно это — возвращает то, что возвращает вызванная thunk-функция.
Наиболее распространённый вариант использования — возврат промиса из thunk. Это позволяет коду, который диспатчит thunk, дождаться завершения асинхронной работы через промис. Часто используется компонентами для координации дополнительных задач:
const onAddTodoClicked = async () => {
await dispatch(saveTodo(todoText))
setTodoText('')
}
Также существует интересный приём: вы можете использовать thunk для одноразового получения данных из состояния Redux, когда у вас есть доступ только к dispatch. Поскольку диспатч thunk возвращает результат выполнения функции, вы можете написать thunk, который принимает селектор, немедленно вызывает его с текущим состоянием и возвращает результат. Это полезно в React-компонентах, где есть доступ к dispatch, но нет к getState.
// In your Redux slices:
const getSelectedData = selector => (dispatch, getState) => {
return selector(getState())
}
// in a component
const onClick = () => {
const todos = dispatch(getSelectedData(selectTodos))
// do more logic with this data
}
Хотя это и не рекомендуется как стандартная практика, семантически такой подход допустим и будет работать корректно.
Использование createAsyncThunk
Написание асинхронной логики через thunks может быть утомительным. Каждый thunk обычно требует определения трёх типов действий + соответствующих action creators для состояний "pending/fulfilled/rejected", плюс сам action creator и функция thunk. Также нужно обрабатывать крайние случаи с ошибками.
Redux Toolkit предоставляет API createAsyncThunk, которое абстрагирует процесс генерации этих действий, их диспатч на основе жизненного цикла Promise и корректной обработки ошибок. Оно принимает строку с частичным названием типа действия (используется для генерации типов pending, fulfilled и rejected) и "payload creation callback", выполняющий фактический асинхронный запрос и возвращающий Promise. Затем оно автоматически диспатчит действия до и после запроса с правильными аргументами.
Поскольку это абстракция для конкретного случая асинхронных запросов, createAsyncThunk не покрывает все возможные сценарии использования thunks. Для синхронной логики или кастомного поведения вам всё равно следует писать "обычный" thunk вручную.
Action creator thunk содержит прикреплённые action creators для состояний pending, fulfilled и rejected. Вы можете использовать опцию extraReducers в createSlice, чтобы отслеживать эти типы действий и соответствующим образом обновлять состояние среза.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
// omit imports and state
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit reducer cases
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
}
})
Загрузка данных с помощью RTK Query
Redux Toolkit включает новый API для загрузки данных RTK Query. RTK Query — специализированное решение для загрузки и кэширования данных в Redux-приложениях, которое позволяет полностью избежать написания thunks и редюсеров для управления загрузкой данных.
RTK Query внутренне использует createAsyncThunk для всех запросов вместе с кастомным middleware для управления временем жизни кэшированных данных.
Сначала создайте "API slice" с описанием серверных эндпоинтов вашего приложения. Для каждого эндпоинта автоматически генерируется React-хук с именем, основанным на эндпоинте и типе запроса, например useGetPokemonByNameQuery:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: builder => ({
getPokemonByName: builder.query({
query: (name: string) => `pokemon/${name}`
})
})
})
export const { useGetPokemonByNameQuery } = pokemonApi
Затем добавьте сгенерированный редюсер API slice и кастомное middleware в хранилище:
import { configureStore } from '@reduxjs/toolkit'
// Or from '@reduxjs/toolkit/query/react'
import { setupListeners } from '@reduxjs/toolkit/query'
import { pokemonApi } from './services/pokemon'
export const store = configureStore({
reducer: {
// Add the generated reducer as a specific top-level slice
[pokemonApi.reducerPath]: pokemonApi.reducer
},
// Adding the api middleware enables caching, invalidation, polling,
// and other useful features of `rtk-query`.
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(pokemonApi.middleware)
})
Наконец, импортируйте сгенерированный React-хук в компонент и вызовите его. Хук автоматически загрузит данные при монтировании компонента, а при повторном использовании с теми же аргументами в разных компонентах — предоставит кэшированный результат:
import { useGetPokemonByNameQuery } from './services/pokemon'
export default function Pokemon() {
// Using a query hook automatically fetches data and returns query values
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')
// rendering logic
}
Мы рекомендуем попробовать RTK Query — он может значительно упростить код загрузки данных в ваших приложениях.
Дополнительные материалы
-
Обоснование использования middleware и побочных эффектов:
-
Учебные материалы по thunk: