However, real-world applications need to perform more than just simple action dispatches. You need to enhance Redux’s capability using middleware libraries like Redux Thunk, Redux-Saga, and the recently released listener middleware to manage side effects and more complex synchronous and asynchronous processes.
Since Redux Toolkit (RTK) became the de facto toolset for writing modern Redux code, it is accurate to say that Redux Thunk also became the default middleware, because it is part of RTK by default. However, you can use a different middleware library if the default doesn’t meet your use case.
Despite its simplicity, thunks have limitations. One of the most cited limitations is the inability to run code in response to dispatched actions or state updates. Doing so requires writing custom middleware or using more powerful middleware libraries like Redux-Saga. Thunks are also relatively difficult to test.
The reason for creating the new listener middleware is to fill that void. In this article, I will compare the new listener middleware with Redux-Saga and highlight some of the cross-cutting features. Before doing so, let me introduce the listener middleware and Redux-Saga in the following sections.
Introduction to Redux Toolkit’s new listener middleware
As mentioned above, the Redux maintainers mooted the new listener middleware functionality to enhance the capability of RTK and offer an in-house solution to most of the use cases covered by Sagas. It has finally landed in RTK v1.8.0 after endless iterations. The middleware’s primary functionality is let users respond to dispatched actions, or run code in response to state updates.
According to the maintainers, the listener middleware is conceptually similar to React’s
useEffect hook. The
useEffect hook is for running side effects in React functional components. It runs immediately on component mount and on subsequent re-renders when one of its dependencies has changed.
Similarly, you have absolute control over when a listener runs. You can register a listener to run when some actions are dispatched, on every state update, or after meeting certain conditions. To use the listener middleware, import the
createListenerMiddleware function like any other RTK functionality. It is available in RTK v1.8.0 or later.
import configureStore, createListenerMiddleware from "@reduxjs/toolkit"; const listenerMiddleware = createListenerMiddleware();
You can add listeners to the middleware statically during setup or add and remove them dynamically at runtime. To add it statically at setup, you need to invoke the
startListening method of the middleware instance like so:
listenerMiddleware.startListening( actionCreator: addTodo, effect: async (action, listenerApi) => console.log(listenerApi.getOriginalState()); console.log(action); await listenerApi.delay(5000); console.log(listenerApi.getState()); , );
effect callback will run after dispatching the specified action in the example above. The
effect callback takes two parameters by default: the dispatched action, and the listener API object. The listener API object has functions such as
getState for interacting with the Redux store.
In the listener’s
effect callback, you can perform side effects, cancel running listener instances, spawn child processes, dispatch actions, and access the state.
If you want to remove or add the listener dynamically at runtime, you can dispatch standard built-in actions. When registering the listener callback, you can specify when it will run strictly using one of the following properties:
- Action type: the
typeproperty is the exact action type string that will trigger the
- Action creator: the
actionCreatorproperty is the exact action creator that triggers the
- Matcher: the
matcherproperty matches one of many actions using RTK matcher and triggers the
effectcallback when there is a match
- Predicate: the
predicateproperty is a function that returns
falseto determine whether the
effectcallback should run or not. It has access to the dispatched action and current and previous states
The properties outlined above belong to the object you pass to the
startListening function when adding a listener to the middleware.
With RTK, you can add the listener middleware like any other middleware:
export const store = configureStore( reducer: rootReducer, middleware: (getDefaultMiddleWare) => return getDefaultMiddleWare( thunk: false ).prepend(listenerMiddleware); );
What is Redux-Saga?
Redux-Saga is one of the popular middleware libraries for Redux. It makes working with asynchronous operations and side effects a lot easier. Unlike other libraries like Redux Thunk, Redux-Saga uses ES6 generators. Therefore, you should be knowledgeable in ES6 generators to implement Sagas correctly.
You can declare a generator function using the
function* construct. Below is a basic example of a generator function. If it looks unfamiliar to you, follow the link in the opening paragraph to understand iterators and generators before continuing:
function* countToThree() yield 1; yield 2; yield 3; const counter = countToThree(); console.log(counter.next()); // value: 1, done: false console.log(counter.next()); // value: 2, done: false console.log(counter.next()); // value: 3, done: false
Invoking a generator function doesn’t execute the function body like regular functions do. It instead returns an iterator object with the
next method for executing the function body.
next method will execute the function body until it encounters the first
yield keyword. It pauses execution and returns an object with the properties
value property holds the value yielded, and
done is a boolean specifying whether all the values have been yielded.
next again will resume function execution until it encounters the next
yield. It again pauses execution and returns an object with the properties
done like before. This process continues as you continue invoking
A typical Redux-Saga middleware setup has watcher Sagas and worker Sagas. The watcher Sagas are generator functions that watch for dispatched actions. Worker Sagas are generator functions you yield from watcher Sagas and are usually responsible for performing side effects.
The code below is a simple illustration of how you can implement watcher and worker Sagas:
const fetchTodo = (url) => fetch(url).then((res) => res.json()); function* workerSaga(action) const url = action.payload; try const todo = yield call(fetchTodo, url); yield put(addTodo(todo)); catch (error) yield put(setError( error )); ; function* watcherSaga() yield takeEvery(fetchTodo.toString(), workerSaga); ;
takeEvery are helper effects and are part of the Redux-Saga API. Check the documentation for more on how they work.
watcher``Saga Generator function runs for every
dispatch of the specified action. In the worker Saga, you can run side effects, access state, dispatch actions, and cancel running processes.
If you are using Redux-Saga with RTK, you can add it to the middleware list like any other middleware:
const SagaMiddleware = createSagaMiddleware(); export const store = configureStore( reducer: rootReducer, middleware: (getDefaultMiddleware) => return getDefaultMiddleware( thunk: false ).prepend(SagaMiddleware); , ); SagaMiddleware.run(rootSaga);
Comparing the new listener middleware with Redux-Saga
The previous sections introduced you to the listener middleware and Redux-Saga. As pointed out, the listener middleware covers most of the primary Redux-Saga use cases. We shall compare some of the functionalities in the listener middleware and Redux-Saga in this section.
Before we get started, it is worth mentioning that the listener middleware’s
effect callback runs after invoking the root reducer and updating state. Therefore, if your goal is to strictly update state from the
effects callback, dispatch an action that will trigger the
effect without updating the state. After that, you can run some side effect logic and dispatch another action to update state from within the
Delaying effect execution
With the new listener middleware, it is possible to pause or delay the execution of the
effect callback. The
delay function delays code execution within the
effect callback for a specific duration and resumes after that. It takes the number of milliseconds as an argument and returns a Promise which resolves after the specified milliseconds.
delay function is part of the listener API object. You can use it like so in the listener middleware:
listenerMiddleware.startListening( actionCreator: fetchTodo, effect: async (action, listenerApi) => const todoId = action.payload; const todo = await api.fetchTodo(todoId); await listenerApi.delay(500); listenerApi.dispatch(addTodo(todo)); , );
Redux-Saga also has a
delay function, similar to the
delay function of the listener middleware. It takes the number of milliseconds as an argument and delays for the specified duration.
Below is the equivalent implementation of the above functionality in Redux-Saga:
function* fetchTodo(action) const todoId = action.payload; const todo = yield api.fetchTodo(todoId); yield delay(500); yield put(addTodo(todo));
The listener middleware doesn’t have built-in functionality for debouncing like Redux-Saga. However, you can use functions such as
delay to implement similar functionality. They are part of the listener API object.
cancelActiveListeners will cancel all other running instances of a listener except the one that invoked it. You can then delay execution for a specific duration. The latest listener instance will run to completion when there isn’t any related action dispatch or state update during the delay:
listenerMiddleware.startListening( actionCreator: fetchTodo, effect: async (action, listenerApi) => listenerApi.cancelActiveListeners(); await listenerApi.delay(500); , );
The above listener middleware implementation is similar to the built-in
debounce function in Redux-Saga:
function* watcherSaga() yield debounce(500, fetchTodo.toString(), workerSaga);
Like debouncing, the listener middleware doesn’t have a built-in function for throttling like Redux-Saga. However, you can use the
unsubscribe functions of the listener API object to implement similar functionality. Unsubscribing a listener will remove it from the list of listeners.
You can then use the
delay function to delay execution for a specific duration. During the delay, the middleware will ignore all action dispatches or state updates that are supposed to trigger the
effect callback. You can re-subscribe the listener after that:
listener.startListening( type: fetchTodo.toString(), effect: async (action, listenerApi) => listenerApi.unsubscribe(); console.log('Original state ', listenerApi.getOriginalState()); await listenerApi.delay(1000); console.log('Current state ', listenerApi.getState()); listenerApi.subscribe(); );
You need to call
getOriginalState synchronously otherwise it will throw an error.
The above implementation in the listener middleware is similar to the built-in
throttle function in Redux-Saga:
function* watcherSaga() yield throttle(1000, fetchTodo.toString(), workerSaga)
Watching every action dispatch
In the introduction to the new listener middleware section, I mentioned that you can specify when the
effect callback will trigger in one of four ways. You can use either the action
predicate property of the object you pass to the
The predicate is a function that has access to the dispatched action, the previous, and the current states. The
effect callback runs if the predicate returns
true. Therefore, if it always returns
true, as in the example below, the
effect callback runs on every action dispatch or state update:
listenerMiddleware.startListening( predicate: (action, currState, prevState) => true, effect: async (action, listenerApi) => console.log('Previous state ', listenerApi.getOriginalState()); console.log('Current state ', listenerApi.getstate()); , );
The above functionality in the listener middleware is similar to Redux-Saga’s
takeEvery helper effect with the
* wildcard character. Using
* watches for every incoming action dispatch regardless of its type, and then spawns a new child task. The difference is that the listener middleware runs its
effect callback after state update:
function* watchEveyDispatchAndLog() yield takeEvery('*', logger);
Creating a one-off listener
If you want to create a one-shot listener with the new listener middleware, you can use the
unsubscribe function to remove the listener from the middleware after running some code. Therefore, future dispatches of the same action won’t trigger the
listenerMiddleware.startListening( actionCreator: fetchTodo, effect: async (action, listenerApi) => console.log(action); listenerApi.unsubscribe(); , );
However, note that the
unsubscribe function will not cancel already running instances of the
effect callback. You can cancel running instances using the
cancelActiveListeners function before unsubscribing.
The above functionality is equivalent to using the
take helper effect to specify which action dispatch to watch in Redux-Saga:
function* watchIncrementVisitCount() yield take(incrementVisitCount()); yield api.incrementVisitCount();
The above Saga will strictly take the first dispatch of the specified action and stop watching after that. Though the above example only takes the first dispatch, you can modify it to watch as many dispatches as you want.
Launching child tasks
It is possible to launch child tasks in the listener callback using the listener API’s
fork function. The function
fork takes an asynchronous or synchronous function as an argument. You can use it to execute additional tasks within the
listenerMiddleware.startListening( actionCreator: fetchTodo, effect: async (action, listenerApi) => const task = listenerApi.fork(async (forkApi) => ); const result = await task.result; , );
The above listener middleware functionality is similar to running additional tasks in Redux-Saga with either the
spawn helper effect. The
fork effect creates attached task while
spawn creates detached task:
function* fetchTodos() const todo1 = yield fork(fetchTodo, '1'); const todo2 = yield fork(fetchTodo, '2');
Canceling running listener instances
For multiple running instances of the same listener, the listener middleware provides the
cancelActiveListeners utility function for canceling the other instances in the
effect callback. As a result, the callback runs for the latest dispatch:
listenerMiddleware.startListening( actionCreator: fetchTodo, effect: async (action, listenerMiddlewareApi) => listenerMiddlewareApi.cancelActiveListeners(); , );
The above functionality of the listener middleware is similar to Redux-Saga’s
takeLatest effect creator. The
takeLatest effect creator also cancels previously started Saga tasks, if they are still running, in favor of the latest one:
function* watchFetchTodo() yield takeLatest(addTodo.toString(), fetchTodo); ;
The bundle size of the listener middleware is approximately half that of Redux-Saga. The table below shows the bundle sizes for Redux-Saga and the listener middleware obtained from bundlephobia.
I have also included RTK in the table below because it is the recommended toolset for working with Redux. The listener middleware is bundled with RTK by default. Though RTK is relatively large, it simplifies working with Redux.
|Package||Minified size||Minified + Gzipped size|
|Redux Toolkit (RTK)||39.3kB||12.7kB|
Despite being powerful, one of the most cited downsides of using Redux-Saga is its steep learning curve, especially if you are unfamiliar with generators and Sagas.
Unlike Redux-Saga, the new listener middleware exposes a minimal set of functionalities you can learn very fast. You can then use them flexibly to replicate some of the common Redux-Saga use cases as illustrated in the previous sub-sections.
One of the benefits of using Redux-Saga over its contemporaries like Thunks is that Redux-Saga’s generator functions and the built-in helper effects make testing some of the common patterns straightforward.
Like Redux-Saga, it is easy to test some of the common patterns of the new listener middleware, and there are great examples of how to test some of the common patterns in the documentation.
Overall, the new listener middleware is a simpler and lightweight alternative to Redux-Saga., and picking it up is straightforward. Unless you maintain a codebase that relies heavily on Redux-Saga, it is worth exploring. If it doesn’t meet your use case, you can use Redux-Saga or another middleware with great results.
Though the listener middleware is the missing functionality for RTK to offer in-house solutions to most of the problems Redux-Saga seems to solve, it doesn’t mean that it covers all use cases.
It is also worth mentioning that this article made a basic comparison. We may not know the true power or limitations of the listener middleware until it has been used extensively in production, even though many devs are already using it creatively to solve problems.
If there is anything I have missed, do let me know in the comments section below.
LogRocket: Full visibility into your web apps
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.