Introduction
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.
Contents
- Introduction to Redux Toolkit’s new listener middleware
- What is Redux-Saga?
- Comparing Redux Toolkit’s new listener middleware to Redux-Saga
- Delaying effect execution
- Debouncing
- Throttling
- Watching every action dispatch
- Creating a one-off listener
- Launching child tasks
- Canceling running listener instances
- Bundle size
- Learning curve
- Testing
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()); }, });
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:
- Action type: the
type
property is the exact action type string that will trigger theeffect
callback - Action creator: the
actionCreator
property is the exact action creator that triggers theeffect
callback - Matcher: the
matcher
property matches one of many actions using RTK matcher and triggers theeffect
callback when there is a match - Predicate: the
predicate
property is a function that returnstrue
orfalse
to determine whether theeffect
callback 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.
Generator functions
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
.
Understanding Sagas
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);
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 effect
callback.
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.
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)); }
Debouncing
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); }
Throttling
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) }
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 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); }
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 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.
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 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'); }
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); };
Bundle size
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-Saga | 14kB | 5.3kB |
Listener middleware | 6.6kB | 2.5kB |
Redux Toolkit (RTK) | 39.3kB | 12.7kB |
Learning curve
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.
Testing
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.
Conclusion
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.
The post Redux Toolkit’s new listener middleware vs. Redux-Saga appeared first on LogRocket Blog.
from LogRocket Blog https://ift.tt/QwRESUb
via Read more