This episode covers Redux middleware: Thunk as the foundation of createAsyncThunk, when to write a manual thunk, and createListenerMiddleware for responding to actions centrally with logging, side effects, and debouncing without polluting reducers.

So far all state changes happen through actions dispatched straight to reducers. But what if we need side effects: saving to localStorage, calling an API before dispatching, or waiting a moment before responding? Reducers must stay pure, so side effects are handled in another layer — that's middleware.
Episode 12 dissects two important middlewares. First Redux Thunk, which is the foundation of createAsyncThunk. Second createListenerMiddleware, a modern tool for responding to actions centrally with debounce, logging, and side effects without writing code inside reducers.
A thunk is a function that wraps a deferred operation. Redux Thunk lets us dispatch a function — not just an action object. That function receives dispatch and getState, so it can delay dispatching until an async operation finishes:
export const loginWithEmail = (email: string, password: string) =>
async (dispatch, getState) => {
dispatch(loginPending())
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
const user = await res.json()
dispatch(loginSucceeded(user))
} catch (error) {
dispatch(loginFailed(String(error)))
}
}The thunk middleware detects that the dispatched value is a function, calls it with dispatch and getState, then returns its result. With this pattern, all async logic lives in one place that can be tested.
createAsyncThunk handles the most common pattern: single pending, fulfilled, rejected actions. But there are cases that are still better written manually:
getState() to decide whether a request should be sent.Here's an example of a multi-step flow that reads more expressively as a manual thunk:
export const checkout = (items: CartItem[]) => async (dispatch, getState) => {
const { auth } = getState()
if (!auth.token) {
dispatch(promptLogin())
return
}
dispatch(orderPending())
const order = await createOrder(items, auth.token)
dispatch(orderCreated(order))
dispatch(cartCleared())
trackEvent("checkout_completed", { itemCount: items.length })
}In fact, createAsyncThunk wraps manual thunk logic. It creates a thunk that dispatches three wrapper actions — pending, fulfilled, rejected — so reducers can respond to each stage with extraReducers:
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"
export const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async () => {
const res = await fetch("/api/users")
if (!res.ok) throw new Error("Gagal memuat users")
return res.json()
},
)
const usersSlice = createSlice({
name: "users",
initialState: { items: [] as string[], loading: false },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.items = action.payload
state.loading = false
})
.addCase(fetchUsers.rejected, (state) => {
state.loading = false
})
},
})The three actions are automatically available as thunk properties: fetchUsers.pending, fetchUsers.fulfilled, fetchUsers.rejected. The rule of thumb: use createAsyncThunk for a single simple request, and a manual thunk for multi-step flows.
Listener middleware captures actions and runs side effects in one place. Add it to the store, then register listeners with startListening:
import { createListenerMiddleware } from "@reduxjs/toolkit"
import { logAdded } from "../features/logger/loggerSlice"
export const listenerMiddleware = createListenerMiddleware()
listenerMiddleware.startListening({
matcher: (action) => action.type.endsWith("/rejected"),
effect: async (action, listenerApi) => {
console.error("Action gagal:", action.type, action.error)
listenerApi.dispatch(logAdded(`${action.type} rejected`))
},
})This listener catches every rejected action from thunks and records it. listenerApi provides dispatch, getState, cancelActiveListeners, and delay for timing.
createListenerMiddleware is built for cases that need a delay, such as debounced search that only fires a request after the user stops typing:
import { createListenerMiddleware } from "@reduxjs/toolkit"
import { searchQueryChanged } from "../features/search/searchSlice"
import { fetchResults } from "../features/search/searchSlice"
export const searchListener = createListenerMiddleware()
searchListener.startListening({
actionCreator: searchQueryChanged,
effect: async (action, listenerApi) => {
listenerApi.cancelActiveListeners()
await listenerApi.delay(400)
listenerApi.dispatch(fetchResults(action.payload))
},
})Every time the user types, the listener cancels the previous listener then waits 400 ms. cancelActiveListeners and delay are the main pair for timing — the request is only sent after the pause, and the reducer stays clean because all the timing logic lives in middleware.
Tip
Don't forget to add the middleware to the store: getDefaultMiddleware().prepend(listenerMiddleware.middleware). Use prepend so the listener catches actions before other listeners process them.
Middleware is where side effects live, and Redux Toolkit provides two ways: Thunk for function-based async flows, and createListenerMiddleware for centralized reactions to actions. Thunk suits operations that need full control over sequential dispatch, while listeners excel at logging, debouncing, and cross-feature synchronization — all without dirtying pure reducers.
Key takeaways:
dispatch and getState, executed by the Redux Thunk middleware.createAsyncThunk wraps manual thunks for the single-request pattern with pending, fulfilled, rejected lifecycle.getState-based decisions.createListenerMiddleware runs side effects when certain actions pass through.matcher and actionCreator determine which actions are captured.cancelActiveListeners plus delay produces debouncing without code in the reducer.In the next episode, episode 13 turns to performance — you'll understand reselect and createSelector for memoization, composing derived state, avoiding excessive re-renders, and profiling techniques with Redux DevTools.