跳到主要内容

Side Effects Approaches

What You'll Learn
  • What "side effects" are and how they fit into Redux
  • Common tools for managing side effects with Redux
  • Our recommendations for which tools to use for different use cases

Redux and Side Effects

Side Effects Overview

By itself, a Redux store doesn't know anything about async logic. It only knows how to synchronously dispatch actions, update the state by calling the root reducer function, and notify the UI that something has changed. Any asynchronicity has to happen outside the store.

Redux reducers must never contain "side effects". A "side effect" is any change to state or behavior that can be seen outside of returning a value from a function. Some common kinds of side effects are things like:

  • Logging a value to the console
  • Saving a file
  • Setting an async timer
  • Making an AJAX HTTP request
  • Modifying some state that exists outside of a function, or mutating arguments to a function
  • Generating random numbers or unique random IDs (such as Math.random() or Date.now())

However, any real app will need to do these kinds of things somewhere. So, if we can't put side effects in reducers, where can we put them?

Middleware and Side Effects

Redux middleware were designed to enable writing logic that has side effects.

A Redux middleware can do anything when it sees a dispatched action: log something, modify the action, delay the action, make an async call, and more. Also, since middleware form a pipeline around the real store.dispatch function, this also means that we could actually pass something that isn't a plain action object to dispatch, as long as a middleware intercepts that value and doesn't let it reach the reducers.

Middleware also have access to dispatch and getState. That means you could write some async logic in a middleware, and still have the ability to interact with the Redux store by dispatching actions.

Because of this, Redux side effects and async logic are normally implemented through middleware.

Side Effects Use Cases

In practice, the single most common use case for side effects in a typical Redux app is fetching and caching data from the server.

Another use case more specific to Redux is writing logic that responds to a dispatched action or state change by executing additional logic, such as dispatching more actions.

Recommendations

We recommend using the tools that best fit each use case (see below for the reasons for our recommendations, as well as further details on each tool):

提示

Data Fetching

  • Use RTK Query as the default approach for data fetching and caching
  • If RTKQ doesn't fully fit for some reason, use createAsyncThunk
  • Only fall back to handwritten thunks if nothing else works
  • Don't use sagas or observables for data fetching!

Reacting to Actions / State Changes, Async Workflows

  • Use RTK listeners as the default for responding to store updates and writing long-running async workflows
  • Only use sagas / observables if listeners don't solve your use case well enough

Logic with State Access

  • Use thunks for complex sync and moderate async logic, including access to getState and dispatching multiple actions

Why RTK Query for Data Fetching

Per the React docs section on "alternatives for data fetching in Effects", you should use either data fetching approaches that are built into a server-side framework, or a client-side cache. You should not write data fetching and cache management code yourself.

RTK Query was specifically designed to be a complete data fetching and caching layer for Redux-based applications. It manages all the fetching, caching, and loading status logic for you, and covers many edge cases that are typically forgotten or hard to handle if you write data fetching code yourself, as well as having cache lifecycle management built-in. It also makes it simple to fetch and use data via the auto-generated React hooks.

We specifically recommend against sagas for data fetching because the complexity of sagas is not helpful, and you would still have to write all of the caching + loading status management logic yourself.

Why Listeners for Reactive Logic

We've intentionally designed the RTK listener middleware to be straightforward to use. It uses standard async/await syntax, covers most common reactive use cases (responding to actions or state changes, debouncing, delays), and even several advanced cases (launching child tasks). It has a small bundle size (~3K), is included with Redux Toolkit, and works great with TypeScript.

We specifically recommend against sagas or observables for most reactive logic for multiple reasons:

  • Sagas: require understanding generator function syntax as well as the saga effects behaviors; add multiple levels of indirection due to needing extra actions dispatched; have poor TypeScript support; and the power and complexity is simply not needed for most Redux use cases.
  • Observables: require understanding the RxJS API and mental model; can be difficult to debug; can add significant bundle size

Common Side Effects Approaches

The lowest-level technique for managing side effects with Redux is to write your own custom middleware that listens for specific actions and runs logic. However, that's rarely used. Instead, most apps have historically used one of the common pre-built Redux side effects middleware available in the ecosystem: thunks, sagas, or observables. Each of these has its own different use cases and tradeoffs.

More recently, our official Redux Toolkit package has added two new APIs for managing side effects: the "listener" middleware for writing reactive logic, and RTK Query for fetching and caching server state.

Thunks

The Redux "thunk" middleware has traditionally been the most widely used middleware for writing async logic.

Thunks work by passing a function into dispatch. The thunk middleware intercepts the function, calls it, and passes in theThunkFunction(dispatch, getState). The thunk function can now do any sync/async logic and interact with the store.

Thunk Use Cases

Thunks are best used for complex sync logic that needs access to dispatch and getState, or moderate async logic such as one-shot "fetch some async data and dispatch an action with the result" requests.

We have traditionally recommended thunks as the default approach, and Redux Toolkit specifically includes the createAsyncThunk API for the "request and dispatch" use case. For other use cases, you can write your own thunk functions.

Thunk Tradeoffs

  • 👍: Just write functions; may contain any logic
  • 👎: Can't respond to dispatched actions; imperative; can't be cancelled
Thunk Examples
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

The Redux-Saga middleware has traditionally been the second most common tool for side effects, after thunks. It's inspired by the backend "saga" pattern, where long-running workflows can respond to events triggered throughout the system.

Conceptually, you can think of sagas as "background threads" inside the Redux app, which have the ability to listen to dispatched actions and run additional logic.

Sagas are written using generator functions. Saga functions return descriptions of side effects and pause themselves, and the saga middleware is responsible for executing the side effect and resuming the saga function with the result. The redux-saga library includes a variety of effects definitions such as:

  • call: executes an async function and returns the result when the promise resolves:
  • put: dispatches a Redux action
  • fork: spawns a "child saga", like an additional thread that can do more work
  • takeLatest: listens for a given Redux action, triggers a saga function to execute, and cancels previous running copies of the saga if it's dispatched again

Saga Use Cases

Sagas are extremely powerful, and are best used for highly complex async workflows that require "background thread"-type behavior or debouncing/cancelling.

Saga users have often pointed to the fact that saga functions only return descriptions of the desired effects as a major positive that makes them more testable.

Saga Tradeoffs

  • 👍: Sagas are testable because they only return descriptions of effects; powerful effects model; pause/cancel capabilities
  • 👎: generator functions are complex; unique saga effects API; saga tests often only test implementation results and need to be rewritten every time the saga is touched, making them a lot less valuable; do not work well with TypeScript;
Saga Examples
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

The Redux-Observable middleware lets you use RxJS observables to create processing pipelines called "epics".

Since RxJS is a framework-agnostic library, observable users point to the fact that you can reuse knowledge of how to use it across different platforms as a major selling point. In addition, RxJS lets you construct declarative pipelines that handle timing cases like cancellation or debouncing.

Observable Use Cases

Similar to sagas, observables are powerful and best used for highly complex async workflows that require "background thread"-type behavior or debouncing/cancelling.

Observable Tradeoffs

  • 👍: Observables are a highly powerful data flow model; RxJS knowledge can be used separate from Redux; declarative syntax
  • 👎: RxJS API is complex; mental model; can be hard to debug; bundle size
Observable Examples
// 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 includes the createListenerMiddleware API to handle "reactive" logic. It's specifically intended to be a lighter-weight alternative to sagas and observables that handles 90% of the same use cases, with a smaller bundle size, simpler API, and better TypeScript support.

Conceptually, this is similar to React's useEffect hook, but for Redux store updates.

The listener middleware lets you add entries that match against actions to determine when to run the effect callback. Similar to thunks, an effect callback can be sync or async, and have access to dispatch and getState. They also receive a listenerApi object with several primitives for building async workflows, such as:

  • condition(): pauses until a certain action is dispatched or state change occurs
  • cancelActiveListeners(): cancel existing in-progress instances of the effect
  • fork(): creates a "child task" that can do additional work

These primitives allow listeners to replicate almost all of the effects behaviors from Redux-Saga.

Listener Use Cases

Listeners can be used for a wide variety of tasks, such as lightweight store persistence, triggering additional logic when an action is dispatched, watching for state changes, and complex long-running "background thread"-style async workflows.

In addition, listener entries can be added and removed dynamically at runtime by dispatching special add/removeListener actions. This integrates nicely with React's useEffect hook, and can be used for adding additional behavior that corresponds to a component's lifetime.

Listener Tradeoffs

  • 👍: Built into Redux Toolkit; async/await is more familiar syntax; similar to thunks; lightweight concepts and size; works great with TypeScript
  • 👎: Relatively new and not as "battle-tested" yet; not quite as flexible as sagas/observables
Listener Examples
// 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 includes RTK Query, a purpose-built data fetching and caching solution for Redux apps. It's designed to simplify common cases for loading data in a web application, eliminating the need to hand-write data fetching & caching logic yourself.

RTK Query relies on creating an API definition consisting of many "endpoints". An endpoint can be a "query" for fetching data, or a "mutation" for sending an update to the server. RTKQ manages fetching and caching data internally, including tracking usage of each cache entry and removing cached data that's no longer needed. It features a unique "tag" system for triggering automatic refetches of data as mutations update state on the server.

Like the rest of Redux, RTKQ is UI-agnostic at its core, and can be used with any UI framework. However, it also comes with React integration built in, and can automatically generate React hooks for each endpoint. This provides a familiar and simple API for fetching and updating data from React components.

RTKQ provides a fetch-based implementation out of the box, and works great with REST APIs. It's also flexible enough to be used with GraphQL APIs, and can even be configured to work with arbitrary async functions, allowing integration with external SDKs such as Firebase, Supabase, or your own async logic.

RTKQ also has powerful capabilities such as endpoint "lifecycle methods", allowing you to run logic as cache entries are added and removed. This can be used for scenarios like fetching initial data for a chat room, then subscribing to a socket for additional messages that are used to update the cache.

RTK Query Use Cases

RTK Query is specifically built to solve the use case of data fetching and caching of server state.

RTK Query Tradeoffs

  • 👍: Built into RTK; eliminates the need to write any code (thunks, selectors, effects, reducers) for managing data fetching and loading state; works great with TS; integrates into the rest of the Redux store; built-in React hooks
  • 👎: Intentionally a "document"-style cache, rather than "normalized"; Adds a one-time additional bundle size cost
RTK Query Examples
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
}

Other Approaches

Custom Middleware

Given that thunks, sagas, observables, and listeners are all forms of Redux middleware (and RTK Query includes its own custom middleware), it's always possible to write your own custom middleware if none of these tools sufficiently handles your use cases.

Note that we specifically recommend against trying to use custom middleware as a technique for managing the bulk of your app's logic! Some users have tried creating dozens of custom middleware, one per specific app feature. This adds significant overhead, as each middleware has to run as part of each call to dispatch. It's better to use a general-purpose middleware such as thunks or listeners instead, where there's a single middleware instance added that can handle many different chunks of logic.

Custom Middleware Example
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

Many apps use websockets or some other form of persistent connection, primarily to receive streaming updates from the server.

We generally recommend that most websocket usage in a Redux app should live inside a custom middleware, for several reasons:

  • Middleware exist for the lifetime of the application
  • Like with the store itself, you probably only need a single instance of a given connection that the whole app can use
  • Middleware can see all dispatched actions and dispatch actions themselves. This means a middleware can take dispatched actions and turn those into messages sent over the websocket, and dispatch new actions when a message is received over the websocket.
  • A websocket connection instance isn't serializable, so it doesn't belong in the store state itself

Depending on the needs of the application, you could create the socket as part of the middleware init process, create the socket on demand in the middleware by dispatching an initialization action, or create it in a separate module file so it can be accessed elsewhere.

Websockets can also be used in an RTK Query lifecycle callback, where they could respond to messages by applying updates to the RTKQ cache.

XState

State machines can be very useful for defining possible known states for a system and the possible transitions between each state, as well as triggering side effects when a transition occurs.

Redux reducers can be written as true Finite State Machines, but RTK doesn't include anything to help with this. In practice, they tend to be partial state machines that really only care about the dispatched action to determine how to update the state. Listeners, sagas, and observables can be used for the "run side effects after dispatch" aspect, but can sometimes require more work to ensure a side effect only runs at a specific time.

XState is a powerful library for defining true state machines and executing them, including managing state transitions based on events and triggering related side effects. It also has related tools for creating state machine definitions via a graphical editor, which can then be loaded into the XState logic for execution.

While there currently is no official integration between XState and Redux, it is possible to use an XState machine as a Redux reducer, and the XState developers have created a useful POC demonstrating using XState as a Redux side effects middleware:

Further Information