Redux Toolkit’s new listener middleware vs. Redux-Saga – LogRocket Blog


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

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 the effect callback
  • Action creator: the actionCreator property is the exact action creator that triggers the effect callback
  • Matcher: the matcher property matches one of many actions using RTK matcher and triggers the effect callback when there is a match
  • Predicate: the predicate 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 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.

PackageMinified sizeMinified + Gzipped size
Redux-Saga14kB5.3kB
Listener middleware6.6kB2.5kB
Redux Toolkit (RTK)39.3kB12.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.

: 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.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.



Source link

Leave a Reply

Your email address will not be published.

Previous Article

Dillinger Labs Dani & Streetheart Review – Wireless Audio For DJs

Next Article

JavaScript Cheat Sheet - A Basic Guide to JavaScript - GeeksforGeeks

Related Posts