| name | orchestrate-side-effects/handle-side-effects |
| description | Use this when choosing between RTK Query, createAsyncThunk, handwritten thunks, and createListenerMiddleware. Covers imperative versus reactive workflows, listener middleware setup, and keeping side effects out of reducers and UI components.
|
| type | core |
| library | @reduxjs/toolkit |
| library_version | 2.11.2 |
| requires | ["build-modern-redux-apps/redux-dataflow"] |
| sources | ["reduxjs/redux-toolkit:docs/api/createAsyncThunk.mdx","reduxjs/redux-toolkit:docs/api/createListenerMiddleware.mdx","reduxjs/redux:docs/style-guide/style-guide.md","reduxjs/redux:docs/tutorials/essentials/part-5-async-logic.md"] |
Handle Side Effects
Setup
import {
configureStore,
createListenerMiddleware,
createSlice,
} from '@reduxjs/toolkit'
const docsSlice = createSlice({
name: 'docs',
initialState: { status: 'idle' as 'idle' | 'saved' },
reducers: {
saveStarted(state) {
state.status = 'idle'
},
saveFinished(state) {
state.status = 'saved'
},
},
})
const listenerMiddleware = createListenerMiddleware()
export const store = configureStore({
reducer: {
docs: docsSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const startAppListening =
listenerMiddleware.startListening.withTypes<RootState, AppDispatch>()
Core Patterns
Use RTK Query for server cache by default
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
type Post = { id: string; title: string }
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post'],
endpoints: (build) => ({
getPosts: build.query<Post[], void>({
query: () => 'posts',
providesTags: ['Post'],
}),
}),
})
If the problem is server data that should be cached and re-used, start with RTK Query instead of a thunk.
Use createAsyncThunk for imperative workflows
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
type Draft = { title: string }
export const draftSaved = createAsyncThunk(
'drafts/save',
async (draft: Draft) => {
const response = await fetch('/api/drafts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draft),
})
return (await response.json()) as { id: string; title: string }
},
)
const draftsSlice = createSlice({
name: 'drafts',
initialState: { status: 'idle' as 'idle' | 'pending' | 'failed' },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(draftSaved.pending, () => {
state. =
})
.(draftSaved., {
state. =
})
.(draftSaved., {
state. =
})
},
})
Use a thunk when you need one imperative async workflow with dispatch and getState.
Use listener middleware for reactive workflows
import { createListenerMiddleware, createSlice } from '@reduxjs/toolkit'
const docsSlice = createSlice({
name: 'docs',
initialState: { status: 'idle' as 'idle' | 'saved' },
reducers: {
saveFinished(state) {
state.status = 'saved'
},
},
})
const notificationsSlice = createSlice({
name: 'notifications',
initialState: [] as string[],
reducers: {
notificationQueued(state, action: { payload: string }) {
state.push(action.payload)
},
},
})
const listenerMiddleware = createListenerMiddleware()
listenerMiddleware.startListening({
actionCreator: docsSlice.actions.saveFinished,
effect: async (_action, listenerApi) => {
listenerApi.dispatch(
notificationsSlice.actions.notificationQueued('Document saved'),
)
},
})
Listeners fit workflows that react to future actions or state changes over time instead of driving one imperative request from a single callsite.
Common Mistakes
CRITICAL Running side effects inside reducers
Wrong:
const todosSlice = createSlice({
name: 'todos',
initialState: [] as { id: string }[],
reducers: {
todoSaved(state, action: { payload: { id: string } }) {
fetch('/api/todos', { method: 'POST' })
state.push(action.payload)
},
},
})
Correct:
import { createAsyncThunk } from '@reduxjs/toolkit'
const todoSaved = createAsyncThunk('todos/save', async (todo: { id: string }) => {
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
})
return todo
})
Reducers must stay pure even when Immer is available.
Source: reduxjs/redux:docs/style-guide/style-guide.md
HIGH Using thunks to watch future state changes
Wrong:
export const waitForSave = () => async (
_dispatch: unknown,
getState: () => { docs: { status: string } },
) => {
while (getState().docs.status !== 'saved') {
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
Correct:
startAppListening({
predicate: (_action, currentState) => currentState.docs.status === 'saved',
effect: async () => {
console.log('Document saved')
},
})
Polling inside thunks fights the architecture; listener middleware is the reactive tool.
Source: reduxjs/redux-toolkit:docs/api/createListenerMiddleware.mdx
HIGH Appending listener middleware after the default checks
Wrong:
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'
const reducer = (state = { ready: true }) => state
const listenerMiddleware = createListenerMiddleware()
const store = configureStore({
reducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(listenerMiddleware.middleware),
})
Correct:
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'
const reducer = (state = { ready: true }) => state
const listenerMiddleware = createListenerMiddleware()
const store = configureStore({
reducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
})
Listener add and remove actions may carry functions, so the listener middleware needs to run before serializability checks.
Source: reduxjs/redux-toolkit:docs/api/createListenerMiddleware.mdx
References