Editor’s note: This article was updated on 23 June, 2022 to reflect more accurate information about Redux Thunk, as brought to our attention in the comments section by Redux maintainer Mark Erikson.
Redux is one of the most popular state management libraries in JavaScript, especially among React developers. It makes managing complex UI states easier. Redux reducers, the main building blocks of Redux, are pure functions by design. You initiate state updates by dispatching simple synchronous actions with plain vanilla Redux.
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.
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()); }, });
The 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 dispatch
and 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:
type
property is the exact action type string that will trigger the effect
callbackactionCreator
property is the exact action creator that triggers the effect
callbackmatcher
property matches one of many actions using RTK matcher and triggers the effect
callback when there is a matchpredicate
property is a function that returns true
or false
to determine whether the effect
callback should run or not. It has access to the dispatched action and current and previous statesThe 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); } });
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.
Invoking the 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
and done
. The value
property holds the value yielded, and done
is a boolean specifying whether all the values have been yielded.
Invoking next
again will resume function execution until it encounters the next yield
. It again pauses execution and returns an object with the properties value
and done
like before. This process continues as you continue invoking next
.
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); };
The functions call
, put
, and takeEvery
are helper effects and are part of the Redux-Saga API. Check the documentation for more on how they work.
The 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);
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 effect
callback.
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.
The 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 cancelActiveListeners
and delay
to implement similar functionality. They are part of the listener API object.
Invoking 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 subscribe
, delay
, and 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) }
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 type
, actionCreator
, matcher
, or predicate
property of the object you pass to the startListening
function.
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 takeEvery
with *
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); }
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 effect
callback:
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.
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 effect
callback:
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 fork
or 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'); }
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.
Package | Minified size | Minified + Gzipped size |
---|---|---|
Redux-Saga | 14kB | 5.3kB |
Listener middleware | 6.6kB | 2.5kB |
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.
With the addition of the new listener middleware, RTK now includes both Redux thunk and the new listener middleware by default. You may be confused about which to choose and for what purpose.
Thanks to Mark Erikson, one of the core maintainers of RTK, for highlighting in the comments section below that you can use RTK Query for data fetching, Thunks for logic that requires talking to the store, and listeners if your code needs to react to actions or state changes. If the default functionalities in RTK do not meet your use case, you can reach for Sagas as a last resort.
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.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowCreate a multi-lingual web application using Nuxt 3 and the Nuxt i18n and Nuxt i18n Micro modules.
Use CSS to style and manage disclosure widgets, which are the HTML `details` and `summary` elements.
React Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
2 Replies to "Redux Toolkit’s new listener middleware vs. Redux-Saga"
Hiya, I’m a Redux maintainer and the person who drove development of the listener middleware. Very nice post!
A few quick thoughts:
I actually covered our current recommendations for which Redux middleware to use when in my recent Reactathon talk “The Evolution of Redux Async Logic”: https://blog.isquaredsoftware.com/2022/05/presentations-evolution-redux-async-logic/ .
As a TL;DW: use RTK Query for data fetching; thunks for logic that just needs to talk to the store; and listeners if your code needs to react to actions or state changes. Only reach for sagas as a last resort if no other tool works.
Per the bundle sizes table – this is a bit confusing and misleading, because it’s comparing two specific APIs vs the entire RTK library. In addition, that “12.7K min+gz for all of RTK” _also_ includes the size of Immer, Reselect, and the Redux core.
Also, RTK _does_ tree-shake. If I just use `configureStore` and `createSlice`, you won’t pay the cost of including `createAsyncThunk`, `createEntityAdapter`, or `createListenerMiddleware`. So, yes, the _entire_ lib is 12.7K min+gz including dependencies, but most apps aren’t using every single API in RTK.
I’m actually very curious where you got the “6.6K min” size for the listener middleware. With how Bundlephobia works, and because we ship our package as a single file per module build artifact, that isn’t broken out separately. When the middleware was still a standalone package for alpha testing, it looked like the “modern” bundle file for that build was coming in right at 4K min
I just did some hand-inspection of the `cjs.production.min` and `esm.modern.min` build artifacts in our published package, and pulled out _just_ the listener middleware code. Looks like the “ESM modern” code is only 3.8K min, and the “CJS compat” code is 5.1K min. Running each of those through GZIP, I get 1.7K min+gz for “modern”, and 2K min+gz for “CJS compat”. So, the table isn’t too far off, but actual code included in the bundle _should_ be a bit better than what’s listed 🙂
I appreciate you taking the time to write this article, and put together the comparisons. Glad to hear that you’ve found the listener middleware “easy to learn”!
Hi @Acemarke, Joseph here. Thanks for reading and taking time to provide feedback. I do agree Bundlephobia’s estimates are far from precise. I will update the article and highlight it in the corresponding section. And include the other points you raised. I believe readers will find them useful as well.
Thanks once again for your time.