Подходы к Побочным Эффектам
Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →
- Что такое "побочные эффекты" и как они работают в Redux
- Распространённые инструменты управления побочными эффектами в Redux
- Наши рекомендации по выбору инструментов для разных сценариев
Redux и Побочные Эффекты
Обзор Побочных Эффектов
Само по себе хранилище Redux ничего не знает об асинхронной логике. Оно умеет только синхронно диспетчеризовать действия, обновлять состояние путём вызова корневого редюсера и уведомлять UI об изменениях. Любая асинхронность должна происходить вне хранилища.
Редюсеры Redux никогда не должны содержать "побочных эффектов". "Побочный эффект" — это любое изменение состояния или поведения, заметное за пределами возвращаемого функцией значения. Типичные примеры побочных эффектов:
-
Вывод значения в консоль
-
Сохранение файла
-
Установка асинхронного таймера
-
Выполнение AJAX HTTP запроса
-
Изменение состояния вне функции или мутация аргументов функции
-
Генерация случайных чисел или уникальных ID (например,
Math.random()илиDate.now())
Однако любому реальному приложению где-то нужно выполнять подобные операции. И если мы не можем размещать побочные эффекты в редюсерах, где можно их размещать?
Middleware и Побочные Эффекты
Middleware Redux были разработаны для реализации логики с побочными эффектами.
Middleware Redux может выполнять любые действия при получении диспатченного экшена: логировать данные, изменять экшен, задерживать его выполнение, делать асинхронные вызовы и другое. Поскольку middleware образуют конвейер вокруг настоящей функции store.dispatch, это также означает, что мы можем передать в dispatch что-то, что не является обычным объектом экшена, при условии, что middleware перехватит это значение и не допустит его попадания в редюсеры.
Middleware также имеют доступ к dispatch и getState. Это означает, что вы можете написать асинхронную логику внутри middleware и сохранить возможность взаимодействия с Redux-хранилищем путём диспетчеризации экшенов.
По этой причине побочные эффекты и асинхронная логика в Redux обычно реализуются через middleware.
Сценарии Использования Побочных Эффектов
На практике самый распространённый сценарий использования побочных эффектов в Redux-приложении — получение и кэширование данных с сервера.
Другой специфический для Redux сценарий — написание логики, которая реагирует на диспатч экшена или изменение состояния выполнением дополнительной логики, например, диспатчем новых экшенов.
Рекомендации
Мы рекомендуем использовать инструменты, наиболее подходящие для каждого сценария (ниже указаны причины рекомендаций и подробности по каждому инструменту):
Получение Данных
- Используйте RTK Query как подход по умолчанию для получения и кэширования данных
- Если RTKQ по какой-то причине не подходит, используйте
createAsyncThunk - Прибегайте к ручному написанию санков только в крайнем случае
- Не используйте саги или observable для получения данных!
Реакция на Экшены / Изменения Состояния, Асинхронные Процессы
- Используйте RTK listeners по умолчанию для реакции на обновления стора и написания долгих асинхронных процессов
- Применяйте саги / observable только если listeners недостаточно для вашего сценария
Логика с Доступом к Состоянию
- Используйте санки для сложной синхронной и умеренной асинхронной логики, включая доступ к
getStateи диспатч нескольких экшенов
Почему RTK Query для Получения Данных
Согласно разделу документации React "альтернативы получению данных в Effects", вы должны использовать либо встроенные в серверные фреймворки подходы, либо клиентский кэш. Вам не следует писать код для получения данных и управления кэшем самостоятельно.
RTK Query был специально разработан как комплексный слой для получения и кэширования данных в Redux-приложениях. Он управляет всей логикой запросов, кэширования и статусов загрузки, покрывает множество граничных случаев, которые обычно упускаются или сложны при самостоятельной реализации, и включает встроенный жизненный цикл кэша. Также он упрощает получение и использование данных через автоматически генерируемые React-хуки.
Мы категорически не рекомендуем саги для получения данных, поскольку их сложность избыточна, и вам всё равно пришлось бы самостоятельно писать всю логику кэширования и управления статусами загрузки.
Почему Listeners для Реактивной Логики
Мы сознательно разработали мидлвару прослушивания RTK так, чтобы она была простой в использовании. Она использует стандартный синтаксис async/await, охватывает большинство распространённых реактивных сценариев (реагирование на действия или изменения состояния, дебаунсинг, задержки), и даже несколько сложных случаев (запуск дочерних задач). Её размер всего ~3 КБ, она входит в состав Redux Toolkit и отлично работает с TypeScript.
Мы специально не рекомендуем использовать саги или обсервабли для большинства реактивной логики по нескольким причинам:
-
Саги: требуют понимания синтаксиса генераторных функций и поведения эффектов саг; добавляют несколько уровней косвенности из-за необходимости диспетчеризации дополнительных действий; имеют слабую поддержку TypeScript; а их мощь и сложность просто избыточны для большинства задач Redux.
-
Обсервабли: требуют понимания API и ментальной модели RxJS; могут быть сложны для отладки; могут значительно увеличить размер бандла.
Распространённые подходы к побочным эффектам
Базовый метод работы с побочными эффектами в Redux — создание собственной мидлвары, которая отслеживает определённые действия и выполняет логику. Однако этот подход редко используется. Вместо этого большинство приложений традиционно используют готовые решения из экосистемы: санки (thunks), саги (sagas) или обсервабли (observables). У каждого из них свои сценарии применения и компромиссы.
Совсем недавно наш официальный пакет Redux Toolkit добавил два новых API для управления побочными эффектами: мидлвару "прослушивания" для реактивной логики и RTK Query для получения и кэширования состояния сервера.
Санки (Thunks)
Мидлвара Redux "thunk" традиционно является самым распространённым инструментом для написания асинхронной логики.
Санки работают путём передачи функции в dispatch. Мидлвара перехватывает эту функцию, вызывает её и передаёт аргументы theThunkFunction(dispatch, getState). Санк-функция может выполнять любую синхронную/асинхронную логику и взаимодействовать с хранилищем.
Сценарии использования санков
Санки лучше всего подходят для сложной синхронной логики, требующей доступа к dispatch и getState, или умеренно сложной асинхронной логики, например для разовых запросов "получить данные и диспетчеризовать действие с результатом".
Мы традиционно рекомендуем санки как подход по умолчанию. Redux Toolkit специально включает API createAsyncThunk для сценария "запрос и диспетчеризация". Для других случаев вы можете писать собственные санк-функции.
Компромиссы санков
-
👍: Просто пишете функции; могут содержать любую логику
-
👎: Не могут реагировать на диспетчеризуемые действия; императивный подход; нельзя отменить
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
// Original "hand-written" thunk fetch request pattern
const fetchUserById = userId => {
return async (dispatch, getState) => {
// Dispatch "pending" action to help track loading state
dispatch(fetchUserStarted())
// Need to pull this out to have correct error handling
let lastAction
try {
const user = await userApi.getUserById(userId)
// Dispatch "fulfilled" action on success
lastAction = fetchUserSucceeded(user)
} catch (err) {
// Dispatch "rejected" action on failure
lastAction = fetchUserFailed(err.message)
}
dispatch(lastAction)
}
}
// Similar request with `createAsyncThunk`
const fetchUserById2 = createAsyncThunk('fetchUserById', async userId => {
const user = await userApi.getUserById(userId)
return user
})
Саги (Sagas)
Мидлвара Redux-Saga традиционно является вторым по популярности инструментом для побочных эффектов после санков. Она вдохновлена бэкенд-паттерном "saga", где длительные рабочие процессы могут реагировать на события в системе.
Концептуально саги можно представить как "фоновые потоки" внутри Redux-приложения, которые могут отслеживать диспетчеризованные действия и выполнять дополнительную логику.
Саги пишутся с использованием генераторных функций. Функции саг возвращают описания побочных эффектов и приостанавливаются, а мидлвара отвечает за выполнение эффекта и возобновление работы саги с результатом. Библиотека redux-saga включает различные определения эффектов:
-
call: выполняет асинхронную функцию и возвращает результат при разрешении промиса -
put: диспетчеризует Redux-действие -
fork: запускает "дочернюю сагу", подобно дополнительному потоку для выполнения работы -
takeLatest: отслеживает указанное Redux-действие, запускает сагу и отменяет предыдущие запущенные экземпляры при повторной диспетчеризации
Сценарии использования саг
Саги исключительно мощны и лучше всего подходят для высокосложных асинхронных рабочих процессов, требующих поведения типа "фонового потока" или дебаунсинга/отмены.
Пользователи саг часто отмечают, что функции саг возвращают лишь описания желаемых эффектов — это ключевое преимущество, повышающее тестируемость.
Плюсы и минусы саг
-
👍: Саги легко тестировать благодаря возврату описаний эффектов; мощная модель эффектов; поддержка приостановки/отмены
-
👎: Генераторы сложны в использовании; уникальный API эффектов саг; тесты часто проверяют лишь реализацию и требуют переписывания при любом изменении саги, снижая их ценность; плохая поддержка TypeScript
import { call, put, takeEvery } from 'redux-saga/effects'
// "Worker" saga: will be fired on USER_FETCH_REQUESTED actions
function* fetchUser(action) {
yield put(fetchUserStarted())
try {
const user = yield call(userApi.getUserById, action.payload.userId)
yield put(fetchUserSucceeded(user))
} catch (err) {
yield put(fetchUserFailed(err.message))
}
}
// "Watcher" saga: starts fetchUser on each `USER_FETCH_REQUESTED` action
function* fetchUserWatcher() {
yield takeEvery('USER_FETCH_REQUESTED', fetchUser)
}
// Can use also use sagas for complex async workflows with "child tasks":
function* fetchAll() {
const task1 = yield fork(fetchResource, 'users')
const task2 = yield fork(fetchResource, 'comments')
yield delay(1000)
}
function* fetchResource(resource) {
const { data } = yield call(api.fetch, resource)
yield put(receiveData(data))
}
Observables
Мидлвара Redux-Observable позволяет использовать RxJS-наблюдаемые для создания конвейеров обработки, называемых "эпиками".
Поскольку RxJS — кроссплатформенная библиотека, пользователи отмечают возможность повторного использования знаний на разных платформах как ключевое преимущество. Кроме того, RxJS позволяет создавать декларативные конвейеры для обработки сценариев с таймингом, таких как отмена или дебаунсинг.
Сценарии использования Observables
Как и саги, наблюдаемые мощны и оптимальны для сложных асинхронных процессов, требующих фонового выполнения или управления дебаунсингом/отменой.
Плюсы и минусы Observables
-
👍: Мощная модель потоков данных; знания RxJS применимы вне Redux; декларативный синтаксис
-
👎: Сложный API RxJS; трудная для освоения ментальная модель; проблемы с отладкой; большой размер бандла
// Typical AJAX example:
const fetchUserEpic = action$ =>
action$.pipe(
filter(fetchUser.match),
mergeMap(action =>
ajax
.getJSON(`https://api.github.com/users/${action.payload}`)
.pipe(map(response => fetchUserFulfilled(response)))
)
)
// Can write highly complex async pipelines, including delays,
// cancellation, debouncing, and error handling:
const fetchReposEpic = action$ =>
action$.pipe(
filter(fetchReposInput.match),
debounceTime(300),
switchMap(action =>
of(fetchReposStart()).pipe(
concat(
searchRepos(action.payload).pipe(
map(payload => fetchReposSuccess(payload.items)),
catchError(error => of(fetchReposError(error)))
)
)
)
)
)
Слушатели (Listeners)
Redux Toolkit включает API createListenerMiddleware для обработки "реактивной" логики. Это более лёгкая альтернатива сагам и наблюдаемым, покрывающая 90% аналогичных сценариев, с меньшим размером бандла, простым API и лучшей поддержкой TypeScript.
Концептуально это похоже на хук React useEffect, но для обновлений Redux-хранилища.
Мидлвара позволяет добавлять обработчики, реагирующие на определённые экшены, чтобы определять, когда запускать колбэк effect. Как и санки, колбэк effect может быть синхронным/асинхронным и иметь доступ к dispatch и getState. Также предоставляется объект listenerApi с примитивами для асинхронных процессов:
-
condition(): приостанавливает выполнение до диспетчеризации экшена или изменения состояния -
cancelActiveListeners(): отменяет текущие выполняющиеся экземпляры эффекта -
fork(): создаёт "дочернюю задачу" для дополнительной работы
Эти примитивы позволяют воспроизвести почти все возможности эффектов Redux-Saga.
Сценарии использования слушателей
Слушатели подходят для различных задач: лёгкое сохранение состояния, триггеры логики при диспетчеризации экшенов, отслеживание изменений состояния и сложные долгие асинхронные процессы фонового типа.
Кроме того, обработчики можно динамически добавлять/удалять в runtime через экшены add/removeListener. Это хорошо интегрируется с хуком React useEffect для привязки логики к жизненному циклу компонента.
Плюсы и минусы слушателей
-
👍: Встроено в Redux Toolkit; знакомый синтаксис
async/await; концептуальная близость к санкам; малый размер; отличная поддержка TypeScript -
👎: Относительно новая технология, меньше "боевого" опыта; чуть менее гибкие, чем саги/наблюдаемые
// Create the middleware instance and methods
const listenerMiddleware = createListenerMiddleware()
// Add one or more listener entries that look for specific actions.
// They may contain any sync or async logic, similar to thunks.
listenerMiddleware.startListening({
actionCreator: todoAdded,
effect: async (action, listenerApi) => {
// Run whatever additional side-effect-y logic you want here
console.log('Todo added: ', action.payload.text)
// Can cancel other running instances
listenerApi.cancelActiveListeners()
// Run async logic
const data = await fetchData()
// Use the listener API methods to dispatch, get state,
// unsubscribe the listener, start child tasks, and more
listenerApi.dispatch(todoAdded('Buy pet food'))
}
})
listenerMiddleware.startListening({
// Can match against actions _or_ state changes/contents
predicate: (action, currentState, previousState) => {
return currentState.counter.value !== previousState.counter.value
},
// Listeners can have long-running async workflows
effect: async (action, listenerApi) => {
// Pause until action dispatched or state changed
if (await listenerApi.condition(matchSomeAction)) {
// Spawn "child tasks" that can do more work and return results
const task = listenerApi.fork(async forkApi => {
// Can pause execution
await forkApi.delay(5)
// Complete the child by returning a value
return 42
})
// Unwrap the child result in the listener
const result = await task.result
if (result.status === 'ok') {
console.log('Child succeeded: ', result.value)
}
}
}
})
RTK Query
Redux Toolkit включает RTK Query — специализированное решение для получения и кэширования данных в Redux-приложениях. Оно упрощает типичные сценарии загрузки данных, избавляя от необходимости писать логику кэширования вручную.
RTK Query основан на создании API-определения, состоящего из множества "эндпоинтов". Эндпоинт может быть "запросом" (query) для получения данных или "мутацией" (mutation) для отправки обновлений на сервер. RTKQ управляет получением и кэшированием данных внутри системы, включая отслеживание использования каждой кэш-записи и удаление данных, которые больше не нужны. Он использует уникальную систему "тегов" для автоматического перезапроса данных при обновлении состояния сервера через мутации.
Как и остальные части Redux, RTKQ изначально не зависит от UI-фреймворков и может использоваться с любым из них. Однако он также включает встроенную интеграцию с React и может автоматически генерировать React-хуки для каждого эндпоинта. Это предоставляет знакомый и простой API для получения и обновления данных из React-компонентов.
RTKQ предоставляет реализацию на основе fetch "из коробки" и отлично работает с REST API. Он также достаточно гибок для использования с GraphQL API и может быть настроен для работы с произвольными асинхронными функциями, что позволяет интегрировать его с внешними SDK, такими как Firebase, Supabase, или вашей собственной асинхронной логикой.
RTKQ также обладает мощными возможностями, такими как "методы жизненного цикла" эндпоинтов, позволяющие выполнять логику при добавлении и удалении записей кэша. Это можно использовать для сценариев вроде получения начальных данных чат-комнаты с последующей подпиской на сокет для обновления кэша новыми сообщениями.
Случаи применения RTK Query
RTK Query специально создан для решения задач получения данных и кэширования серверного состояния.
Компромиссы RTK Query
-
👍: Встроен в RTK; устраняет необходимость писать любой код (санки, селекторы, эффекты, редюсеры) для управления получением данных и состоянием загрузки; отлично работает с TypeScript; интегрируется в Redux-стор; включает встроенные React-хуки
-
👎: Преднамеренно использует кэш в стиле "документов", а не "нормализованный"; добавляет единоразовую дополнительную стоимость в размер бандла
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'
// Create an API definition using a base URL and expected endpoints
export const api = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: builder => ({
getPokemonByName: builder.query<Pokemon, string>({
query: name => `pokemon/${name}`
}),
getPosts: builder.query<Post[], void>({
query: () => '/posts'
}),
addNewPost: builder.mutation<void, Post>({
query: initialPost => ({
url: '/posts',
method: 'POST',
// Include the entire post object as the body of the request
body: initialPost
})
})
})
})
// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = api
export default function App() {
// Using a query hook automatically fetches data and returns query values
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')
// render UI based on data and loading state
}
Другие подходы
Пользовательские мидлвары
Учитывая, что санки, саги, обсерверы и слушатели (listeners) — все это формы мидлваров Redux (а RTK Query включает собственный мидлвар), всегда можно написать свой пользовательский мидлвар, если ни один из этих инструментов не подходит для ваших задач.
Обратите внимание: мы настоятельно не рекомендуем пытаться использовать пользовательские мидлвары как основной способ управления логикой приложения! Некоторые пользователи создавали десятки кастомных мидлваров — по одному на каждую фичу. Это создает значительную нагрузку, так как каждый мидлвар выполняется при каждом вызове dispatch. Лучше использовать универсальные мидлвары (например, санки или слушатели), где один экземпляр может обрабатывать множество логических блоков.
const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
// Delay this action by one second
next(action)
}, 1000)
return
}
return next(action)
}
Websockets
Многие приложения используют websockets или другие формы постоянных соединений, в основном для получения потоковых обновлений с сервера.
Мы обычно рекомендуем размещать большую часть логики websockets в Redux-приложениях внутри кастомного мидлвара по нескольким причинам:
-
Мидлвары существуют на протяжении всего жизненного цикла приложения
-
Как и со стором, вам, вероятно, нужен только один экземпляр соединения, который будет использоваться всем приложением
-
Мидлвары видят все диспатченные экшены и могут диспатчить экшены сами. Это позволяет мидлваре преобразовывать диспатченные экшены в сообщения, отправляемые через вебсокет, и диспатчить новые экшены при получении сообщения.
-
Экземпляр соединения websocket не сериализуем, поэтому он не должен находиться в состоянии стора
В зависимости от потребностей приложения вы можете создавать сокет при инициализации мидлвара, создавать его по требованию через диспетчеризацию экшена инициализации или вынести в отдельный модуль для доступа из других мест.
Websockets также можно использовать в колбэках жизненного цикла RTK Query, где они могут обновлять кэш RTKQ в ответ на сообщения.
XState
Конечные автоматы (state machines) могут быть полезны для определения возможных состояний системы, переходов между ними и запуска побочных эффектов при смене состояний.
Редюсеры Redux могут быть реализованы как полноценные конечные автоматы, но RTK не предоставляет для этого специальных инструментов. На практике они чаще представляют собой частичные автоматы, которые реагируют только на диспетчеризованное действие для обновления состояния. Для аспекта "запуск побочных эффектов после диспетчеризации" можно использовать listeners, саги и observable, но иногда требуется дополнительная работа для гарантии выполнения эффекта в строго определённый момент.
XState — мощная библиотека для определения полноценных конечных автоматов и их выполнения, включая управление переходами состояний на основе событий и запуск связанных побочных эффектов. Она также предоставляет инструменты для создания определений автоматов через графический редактор, которые затем можно загрузить в логику XState для выполнения.
Хотя официальной интеграции между XState и Redux пока не существует, можно использовать XState-автомат как Redux-редюсер. Разработчики XState создали полезное POC, демонстрирующее использование XState в качестве middleware для побочных эффектов Redux:
Дополнительные материалы
-
Презентация: Эволюция асинхронной логики в Redux
-
Обоснование использования middleware и побочных эффектов:
-
Документация и учебные пособия:
- Redux Fundamentals, часть 4: Хранилище > Middleware
- Redux Fundamentals, часть 6: Асинхронная логика и получение данных
- Redux Essentials, часть 5: Асинхронная логика и получение данных
- Redux Essentials, часть 7: Основы RTK Query
- Использование Redux: Реализация логики через thunks
- Redux Toolkit: Обзор RTK Query
-
Статьи и сравнения: