Idiomatic Redux: Designing the Redux Toolkit Listener Middleware

This is a post in the Idiomatic Redux series.


A dive into how we designed the API for the new RTK listener middleware

Intro 🔗︎

In Redux Toolkit 1.8, we released a new "listener" side effects middleware that is intended to be a lightweight alternative to more widely used Redux async middleware like sagas and observables.

The final API and usage of the middleware looks like this:

import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';

import todosReducer, {
  todoAdded,
  todoToggled,
  todoDeleted,
} from '../features/todos/todosSlice';

// Create the middleware instance and methods
const listenerMiddleware = createListenerMiddleware();

// Add one or more listener entries that look for specific actions.
// They may contain any sync or async logic, similar to thunks.
listenerMiddleware.startListening({
  actionCreator: todoAdded,
  effect: async (action, listenerApi) => {
    // Run whatever additional side-effect-y logic you want here
    console.log('Todo added: ', action.payload.text);

    // Can cancel other running instances
    listenerApi.cancelActiveListeners();

    // Run async logic
    const data = await fetchData();

    // Pause until action dispatched or state changed
    if (await listenerApi.condition(matchSomeAction)) {
      // Use the listener API methods to dispatch, get state,
      // unsubscribe the listener, start child tasks, and more
      listenerApi.dispatch(todoAdded('Buy pet food'));

      // Spawn "child tasks" that can do more work and return results
      const task = listenerApi.fork(async (forkApi) => {
        // Can pause execution
        await forkApi.delay(5);
        // Complete the child by returning a value
        return 42;
      });

      const result = await task.result;
      // Unwrap the child result in the listener
      if (result.status === 'ok') {
        // Logs the `42` result value that was returned
        console.log('Child succeeded: ', result.value);
      }
    }
  },
});

const store = configureStore({
  reducer: {
    todos: todosReducer,
  },
  // Add the listener middleware to the store.
  // NOTE: Since this can receive actions with functions inside,
  // it should go before the serializability check middleware
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().prepend(listenerMiddleware.middleware),
});

But how did we end up with that API design?

Trust me, it didn't appear out of thin air :)

The development of this new API was a long and winding process that dates back 2.5 years, and involved significant amounts of iteration to determine what use cases it should cover, what the public API should look like, and how to implement the functionality.

I'd like to recap that process, as I think it's a good example of how to work through designing library APIs and there may be useful lessons for other developers.

Why Create a New Middleware? 🔗︎

Background: Redux Side Effects Approaches 🔗︎

Redux was originally designed to use middleware for customizing side effects behavior with your choice of syntax. Dan and Andrew specifically didn't want to lock users into having to use a single built-in API for async logic, or require users to learn a complex library like RxJS.

The Redux community jumped on this idea, and began cranking out dozens of addon libraries for managing side effects (to the point that less than a year after Redux's release, there was a post complaining there were too many side effects libraries).

But, by late 2017, the ecosystem had mostly coalesced to a few major options:

  • Thunks: dispatch a function, get (dispatch, getState) as arguments, run whatever logic you want in that function
  • Sagas: write generator functions that respond to dispatched actions and return descriptions of side effects ("call this function with these args"), and let the saga middleware do the actual work
  • Observables: write RxJS observable pipelines that respond to dispatched actions and execute side effects

In practice, thunks have always been considered the default. Sagas have been fairly popular. Observables have been somewhat less common - they solve the same use case as sagas, but are mostly used by people who prefer RxJS as an API for declarative behavior rather than the more imperative generator function syntax of sagas.

Redux Toolkit and Thunks 🔗︎

When we designed Redux Toolkit, we specifically wanted to have a good store setup out of the box. We decided early on to automatically set up the thunk middleware by default, because thunks have always been the most common approach for writing async logic. Additionally, we've specifically recommended thunks as the default side effects approach for years, and RTK includes a createAsyncThunk API that abstracts the standard pattern for dispatching actions based on a promise's lifecycle.

RTK's configureStore API allows full customization of the middleware, including replacing the defaults entirely. So, you've always been able to add sagas, observables, or custom middleware to the store, or turn off thunks entirely.

Concerns with Sagas and Observables 🔗︎

Over the years, we've become more opinionated in our advice about how to use Redux. As part of that, we've progressed from "use whatever side effects library you want", to actively discouraging people from using sagas unless absolutely necessary.

We've been asked if we would ever add direct support for sagas to RTK itself, either by including them as a default or having some saga-specific API included. I answered this in my post Why Redux Toolkit Uses Thunks For Async Logic, which points out that:

  • Sagas require understanding both generator functions and the specific redux-saga "effects" APIs
  • Some saga patterns like "watcher" vs "worker" sagas lead to extra boilerplate as well as making logic hard to follow
  • Sagas don't work well with TypeScript
  • Sagas are good for very complex async workflows, but overkill for basic data fetching scenarios

The same concerns apply to observables as well, just with "RxJS operators" instead of "generator functions and saga effects".

These factors all played a role in the eventual development of the listener middleware.

Original Inspiration 🔗︎

In October 2019, I filed an issue for "Add an action listener callback middleware". In that issue, I wrote:

Thunks are easy to use and a good default, but the biggest weakness is that they don't let you respond to dispatched actions. Sagas and observables are very powerful (too powerful for most apps), but they do let you kick off additional logic in response to actions.

I've been considering adding some kind of middleware that would be in between - something that lets you run callback functions in response to specific actions, but without the complex overhead of sagas and observables.

In that issue, I specifically linked a "handler" middleware written by Justin Falcone during his time at Glitch. Justin commented with some details on why it was being used:

There are essentially two schools of thought for working with side effects with redux.

The first approach is to put the side effects in the actions -- e.g. instead of dispatching a simple value, dispatch a promise, or a function that calls side effects. Middleware like redux-promise and redux-thunk intercept these non-value actions and run them.

The second approach is to put the side effects directly into the middleware -- the actions are always simple values, but the middleware listens to these actions, and can run side effects in response to them. This is the approach used by redux-saga and redux-loop; it's also how logging and metrics-reporting middleware work.

I prefer the second approach because it encourages you to keep the "how" with the state, and the "what" with the UI. However, the tools associated with this approach, particularly redux-saga, tend to have a pretty steep learning curve. The approach I'm using here is less expressive, but much closer to the complexity of redux-thunk.

I replied with:

Yep, that's the idea I'm going for here. One possible improvement I'd like to consider is adding and removing subscriptions at runtime. The likely approach would be dispatch(addActionListener(type, callback)).

This formed the basis for the initial design of the middleware: the ability to run callbacks in response to dispatched actions, with a simpler API than sagas or observables, and to add and remove "listener" callbacks at runtime via dispatching.

Initial Design and Implementation Ideas 🔗︎

The same thread then went off into some early design discussions. I tried to lay out some initial constraints and API usage ideas:

  • There should be multiple possible handlers for a single action type. The Glitch middleware linked in the original post does that.
  • It should be possible to add more handlers at runtime by dispatching a "listeners/addListener" action that contains a callback function as the payload, and is explicitly stopped by the middleware from proceeding to the reducers. There should also be some way to remove a listener using the same approach, whether it be based on a returned ID or a function reference equality check.
  • Other than the "add/remove listener" actions, the middleware should probably pass the action through to the reducers first before executing the listeners, same as how sagas work. (I could hypothetically imagine including a way for the callbacks to say "stop this action from proceeding", but it'd be a lot simpler if we don't worry about that.)
  • We're definitely going to pass the "store API" {dispatch, getState} object into the callbacks. Perhaps we should also pass in {addListener, removeListener} as part of that param as well.

At the same time, Lenz Weber tried to put together an initial proof of concept PR just to get something going. Lenz's POC relied on providing a lookup table of action types as another field inside of createSlice, and generated a separate listener middleware instance per slice.

As the discussion continued over the next few days, we debated whether it was better to define listeners inside or outside of slices, whether dynamically adding listeners at runtime via dispatching was a good idea, and the possibility of users tying listeners to components. In the process, I also tossed out an initial suggestion for a "predicate API" that would look like (action, currentState, prevState) => boolean, as a way to check for state changes in addition to specific actions. I also pointed out that a dispatch-based approach for dynamically adding listeners made it the most flexible and easily accessible. If we tried adding a new method to the store itself, the UI wouldn't have access to it, whereas dispatch is universally available. Similarly, dispatch is UI-agnostic and thus would work with any view layer.

Second PR and Use Case Bikeshedding 🔗︎

Initial PR Contents 🔗︎

The original discussion thread continued intermittently for several months. In May 2020, Lenz put together a second implementation attempt in PR #547.

This attempt started with a few interesting ideas:

  • Adding listeners either via middleware.addListener or dispatch(addListener())
  • Matching actions either via type string or by providing an RTK action creator
  • One-shot listeners by providing a once option
  • The ability to actually stop an action from being processed by any other listeners or the reducers, by providing a preventPropagation option
  • A condition callback that could be used to further determine if a listener should run or not

Having a concrete code implementation to look at was great... and it promptly sparked a major wave of bikeshedding :)

One quirk of the initial PR was that the "add listener" action object was actually being passed onwards to the reducers. Since the action originally contained the listener callback function, and those aren't serializable, the middleware was deleting the callback before forwarding the action onwards. The idea was that seeing the "some listener got added" action in the DevTools might be useful for visibility purposes.

PR Iteration and Brainstorming 🔗︎

We debated the value of this for a few days, and at one point Lenz started thinking about dispatching some kind of a "started listening" action. However, he quickly realized this led to too much complexity, and we decided to just drop forwarding the "add" action entirely.

At the same time, a user tried out the PR prototype build, and interestingly jumped straight to trying to add a listener by dispatching from within a component. They ran into a types issue where TS thought that the value returned from dispatch was the plain "add" action object, rather than the unsubscribe callback that should be getting returned. I realized that this was actually a repeat of a types problem Lenz and I had run into a year earlier - trying to get TS to acknowledge that a middleware may alter the return value of dispatch when a specific action is dispatched. (Lenz noted that TS specifically had issues when other fields were attached to the middleware, and said he had "fixed it by brute force". Spoiler: this would come back to bite us later!)

The same user noted that the initial implementation actually ran the listeners before the reducers processed the action, and that meant they couldn't ever see the updated state. Lenz suggested adding a when: 'before' | 'after' option, and added that to the PR. However, I pointed out that both the saga and observable middleware handle effects after the reducers run, and so that should probably be the default.

There was a flurry of discussion activity over the next few days, which led to several PR updates: dropping the once and condition options, defaulting when to 'after', adding listenerApi.unsubscribe, and a consolidation of internal handling for the "before/after" logic. We also did some review and comparison of how other Redux-based libraries and similar custom middleware, to see what their APIs looked like and what capabilities were included.

In the process, I started having some ideas about what methods should be exposed as part of the "listener API", like adding a getPreviousState method:

By passing in getState to the listener, we enable them to read the "current" state at any point in the future, ie, in the middle of some async/await stuff. So yeah, we definitely want getState.

What doesn't give you is the ability to know what the state was before the listener even got triggered at all, which is why I was thinking about getPreviousState.

As a side note: these listeners are definitely way weaker than sagas and observables, in that I don't see how you'd listen for multiple distinct actions in sequence. I'm okay with that, as this is deliberately intended to be a much more scoped API anyway.

(Narrator: This Changed)

At the same time, @mpeyper suggested adding subscribe/unsubscribe methods, and we both came up with the idea of adding an extraArgument similar to the thunk middleware, with the typical use case of injecting a service layer into the middleware. Lenz suggested adding a type guard to enable better typed matching against certain actions, and another user mentioned the idea of waiting for a condition to evaluate to true based on an (action, state) combination. Although we didn't know it at the time, these would all end up becoming parts of the final listener middleware API, albeit after some of these were forgotten and independently reinvented later :)

Stalemate 🔗︎

By late 2020, the conversation on the PR had died off due to a lack of clarity about what the runtime semantics of the middleware should be. The Redux team moved on to working on other new features, including RTK Query. PR #547 was still open, but fell off into the background. Lenz did suggest possibly publishing it as a standalone package under our @rtk-incubator package scope, just to give people a chance to try it out separately.

A couple other users did comment expressing interest. One comment in Dec 2020 said "we're using sagas, but looking for alternatives because those are too complicated". In June 2021, another user dropped by Reactiflux asking some questions, and I pointed them to the PR as a possible solution. They tried it out and left some suggestions on both internal implementation and APIs for adding listeners.

A few days later, a user named Faber Vitale commented and offered suggestions on adding error handling, pointing out that currently any thrown error would kill the entire processing chain. Faber had actually just written the new RTK Query monitor for the Redux DevTools. Faber would end up adding huge contributions to the final version of the middleware.

In late September, another user listed some reasons for wanting to drop sagas:

  • Poor TypeScript support and will never get official TypeScript support according to maintainers.
  • Generator syntax isn't as straightforward as normal JavaScript
  • Thunk actions (action creators that return promises) are sweet and Saga doesn't support
  • No longer actively maintained
  • Redux maintainers generally advise against using it whenever possible unless dealing with really complex asynchronous flows where handling timing overlap of actions is needed.

In general, having some sort of takeLatest/takeLeading esque option on a per function basis would be a game changer. If that were the case there would be practically no reason to use Saga for almost any use case (except maybe a complex auth flow, for example).

It was clear that there was some interest in this middleware - it just needed some actual attention and effort to push it forward.

Restarting the Development Effort 🔗︎

Extracting a Standalone Implementation 🔗︎

In September 2021, I was working on adding some analytics tracking to my day job's app. I specifically wanted to be able to save some analytics events based on dispatched actions. I knew that other analytics-related middleware existed, but I remembered the WIP "action listener" middleware PR and figured it might be a good fit.

The original PR had long grown stale compared to the current RTK release, so I ended up copy-pasting the entire middleware source from the PR directly into our own app. In the process, I also hacked in the ability to pass in a "matcher" function as well to allow matching against multiple possible action types, not even remembering that Lenz had suggested something similar earlier. (I ended up doing this by adding a separate code path for "matchers" vs "type"-based comparisons, which required a chunk of duplicated code at the time.)

Since I'd done the work to make the middleware run as a standalone implementation, I went ahead and pasted the source into a comment in the original PR in case anyone else wanted to try it.

Creating a New Incubator Package 🔗︎

Another couple months went by. In early November, I remembered the middleware again, and finally decided to do something about pushing the effort forward.

By this time we'd turned the RTK repo into a monorepo, containing the original RTK package plus a couple of auxiliary packages related to RTK Query code generation. I copy-pasted the build setup from one of those, and published the standalone middleware source as @rtk-incubator/action-listener-middleware v0.1.0. I then put up RTK discussion #1648: New experimental "action listener" middleware as a thread to house discussion and further iteration on the middleware.

Updating the Design Goals 🔗︎

I took the time to go back through all the prior existing discussions in the issues and PRs, and assembled a list of relevant comments and suggestions on use cases and possible API features. I then wrote an extended comment where I summarized the existing API, listed remaining open questions, and offered my suggestions for design behavior.

I got a couple other comments with some excellent suggestions, like removing the stopPropagation method on the grounds that it was confusing semantically.

Shaping the API 🔗︎

v0.2: Finding the Right Primitives 🔗︎

I spent the next couple nights hacking on several of those ideas, and published v0.2.0 containing the updates.

v0.2 had several important changes: it dropped the stopPropagation method, added an extra argument to middleware creation, added getOriginalState to the listener API, and added some basic try/catch error handling.

However, as I was working on these changes, I already had bigger ideas in mind for further down the road. We'd said from the beginning that "listeners" weren't supposed to replace sagas. After all, sagas were meant for really complex async workflows, and you couldn't do most of the really powerful saga operations like cancellation, throttling, debouncing, forked child jobs, long-running async workflows, or takeLatest without using generator functions.

Or... could you? What if we could replicate most of the functionality of sagas, by adding some key primitives to the listener API design?

This thought started running through my head, and I began having some admittedly grandiose ideas for what this "action listener" middleware could someday become.

At the same time, I happened to be chatting with Shawn Swyx Wang. Shawn was working at a company called Temporal.io, which lets devs write very long-running async workflows as code (like a monthly billing cycle implemented as while(true) { sleep("30days"); billUser(); })). I figured he might have some useful input, given the conceptual similarity of "async workflows".

Shawn specifically pointed me to one of Temporal's workflow APIs as a possible enhancement: condition. Temporal's condition method lets workflows pause themselves until the provided callback function returns true. It also accepts an optional timeout, and returns a Promise<boolean>. That means it can be used as if (await condition(someCheck, timeout)), allowing for both async behavior and easy use with conditional checks.

After looking at that, it was pretty easy to add an equivalent function to the listener API. I wrote it in about 30 lines, and implemented it as a one-shot listener that resolved a "succeeded" promise, raced against a "timeout" promise.

I published the condition method as part of v0.2, and specifically pointed to that in the announcement as an example of enabling "long-running async workflows".

v0.3: Fixing Listener Syntax and Types 🔗︎

At this time, the API for adding a listener still looked like:

middleware.addListener(typeOrActionCreatorOrMatcher, listenerCallback, options);

I wanted to get the action correctly typed inside of listeners, but this wasn't providing type inference because of the overload behavior.

I decided it was best to switch addListener to take a single options object containing several different possible fields for matching against an action. The question was how to get the type inference working correctly. I also wanted to be able to use the same options object typedefs for both middleware.addListener and the "add" action creator. Additionally, at this time the listenerApi.getState method was still hard-typed to return unknown, because there was no way of knowing the actual RootState type before the middleware was created. I figured our best bet was to provide support for declaring "pre-typed" versions of both those functions, similar to how React-Redux exports a TypedUseSelectorHook type.

I was able to get the actual code rearranged to take an options object pretty easily. However, the type inference was refusing to cooperate. I spent a couple days banging my head against it before I acknowledged that my TS-fu simply wasn't strong enough to solve this one myself.

I posted a comment with my WIP code, described the issue, and begged for help. Happily, within the next couple days, I got the assistance I needed. Josh DeGraw gave me an example of extracting the common options as a separate type, and Lenz figured out how to get the overload type inference working correctly.

With that hurdle gone, I published v0.3 with the switch to an options object for adding listeners, as well as the ability to pre-type the "add" methods.

v0.3 also included a couple other key new capabilities. The listenerApi object already had an unsubscribe method. A user suggested adding subscribe as well. This would allow listener callbacks to mimic the takeLeading effect from sagas, by calling unsubscribe at the start to prevent any other instances of that listener from running, and re-subscribing at the end of the callback.

v0.4: "Child Job" Support 🔗︎

From the beginning, we'd said that this "action listener" middleware wasn't supposed to be a full replacement for sagas or observables. However, the addition of the condition and subscribe/unsubscribe methods had gotten me wondering if it might be possible to match the capabilities of sagas after all. I figured it was worth at least doing some research and exploration to see what was feasible, and to do so at this point while the middleware was still in alpha, rather than release something "final" and realize we'd missed some key use cases.

I spent several days doing some significant research on the APIs and capabilities of multiple libraries across the JS ecosystem: redux-saga, redux-observable, redux-logic, xstate, ember-concurrency, and several others, then wrote up my research notes as an extended comment. My conclusions were:

  • The current "action listener" middleware API let us do the equivalent of takeEvery and takeLeading
  • takeLatest would require cancellation support
  • Every library that realistically supported cancellation required use of generator functions, because promises are not inherently cancellable
  • sagas and ember-concurrency both supported "child tasks", and those also required cancellation support

I really didn't want to turn the listener middleware into another generator-based implementation. At that point it would just be a cheap knockoff of sagas, and there'd be no point in it. But, promise cancellation is a quagmire topic - hundreds of people much smarter than me have fought over that in API designs for years, with no good resolution.

A search of NPM for "async cancellation"-related libs did turn up a couple of interesting possibilities. One was https://github.com/ethossoftworks/job-ts , a library that provided a "job" abstraction with promise-based cancellation support. I also found a gist at https://gist.github.com/andrewcourtice/ef1b8f14935b409cfe94901558ba5594 with a "task" API that used AbortController internally.

After playing around with the job-ts library, I decided its "job" abstraction seemed like a good match for how saga child tasks worked. I ended up copying the source over into the middleware package, and wrapped each listener invocation in a Job instance. That enabled me to add a form of cancellation for each running listener instance, and also let listeners kick off child jobs as well.]

Meanwhile, Faber Vitale pointed out that we didn't have an equivalent of the saga take API. The condition method we'd added was very similar, but it returned a boolean and not the matched action. I agreed that would be useful, and Faber filed a PR to add take.

I released v0.4 with take, listener cancellation, and "job" support in early December 2021. With those APIs now available, I was then able to write a test file demonstrating equivalent behavior to the takeLatest, takeLeading, throttle, debounce, fork + join, and fork + cancel saga effects, showing that the WIP "action listener" middleware could now do most of the same things as sagas.

v0.5: "Tasks" and AbortController 🔗︎

The "jobs" API worked, but it added a bit more bundle size than I wanted. The implementation was based on a class, and I'd had success shaving some bytes in React-Redux by converting a class to a closure for better minification results. I gave that a shot here, but didn't see any real improvement.

At the same time, Faber decided to try replacing the "jobs" API entirely. Instead, he reimplemented cancellation behavior and the take/condition methods using a "task" abstraction that was built around AbortController instead. Because fork/delay/pause had been part of the jobs-ts API, we ended up adding those as methods inside of listenerApi instead.

This shaved off about 2K min, leaving the middleware around 4k min in total. I had liked the "jobs" API and concept, but smaller code is a big win, so I shipped that as v0.5.

Polish and Refinement 🔗︎

At this point it felt like we'd settled on the right capabilities, use case support, and general design for the API. The "action listener" middleware could now do most of the same things as sagas, but with a much smaller API and bundle size.

I tried asking for user feedback on Twitter, but wasn't really getting much of a response. So, it was up to us to figure out what else needed tweaking.

v0.6: Simplifying Behavior 🔗︎

Faber pointed out that the original when option was basically useless now that there was no longer an option to cancel action processing. Lenz and I agreed, so I went ahead and ripped that out.

In the process, I noted that listenerApi.cancelPrevious() was misleading, because it actually cancelled all other running instances of that listener regardless of ordering. I ended up renaming it listenerApi.cancelActiveListeners() for clarity.

Those changes went out in v0.6.

I summarized the open questions at that point:

  • We need to review the types for extra and figure out how to get that to carry through properly
  • Not happy with how removeListener is solely based on action type right now
  • Should passing in an entry with the same listener reference still skip adding a new entry and return the existing one, or should we actually have two distinct entries added?
  • Should we add some kind of a "bulk add listeners" API somehow? Is that worth it?
  • Are we happy with the likely usage pattern of having a listenerMiddleware.ts file that creates the middleware and exports it, and then slices would have to import that and call middleware.addListener()? Is there a better setup pattern more akin to createSlice + configureStore(), where a slice file can define an entry that can be imported into listenerMiddleware.ts and added?
  • The middleware return types still aren't working right - const unsubscribe = store.dispatch(addListenerAction()) results in a {type: string, payload: ListenerEntry| type, when it should be an Unsubscribe type. Not sure if this is due to the listener middleware types, configureStore types, or thunk types.

v0.7: Cleanup Improvements 🔗︎

A user asked if there was a way to clear all listeners entirely, particularly for use with cleaning up a shared store instance during tests.

Faber filed a PR to add middleware.clear(). We'd also noted that the "remove" functions only allowed matching based on exact action type, and Faber was able to rewrite them to accept the same options object as the "add" functions for consistency.

We also noted that the existing behavior for the getOriginalState method could potentially lead to memory leaks. getOriginalState captures the value of state before the reducer has a chance to process the action. Since that was always being passed in to each callback as part of listenerApi, that meant that the state reference was being kept around as long as any listener callback was still running.

We fixed that issue by reworking the internals of the middleware so that getOriginalState can only be called synchronously during the original dispatch call stack. If a listener really wants to have access to that value later, it needs to call getOriginalState() right away and save the reference explicitly.

Those fixes went out as v0.7 at the end of January 2022.

TypeScript Issues 🔗︎

I had noted much earlier that there were a couple remaining TS-related issues:

  • createActionListenerMiddleware() accepted an extra argument, but listenerApi.extra was always typed as unknown - the provided value's type wasn't being carried through the rest of the middleware types
  • const unsubscribe = dispatch(addListener()) was typed as returning the {type: 'alm/addListener'} action object, when the code actually returned an Unsubscribe callback

Fixing the extra type behavior was easy. However, as I dug into the unsubscribe type behavior, I just couldn't understand why TS was failing to recognize that the middleware's types changed the return of dispatch when that particular action was passed through.

Lenz took a look, and after some discussion we concluded that there were really two parts of the problem:

  • The types for getDefaultMiddleware() were not doing a sufficient job of inferring any available overrides of dispatch. There was some hardcoded handling internally for the thunk middleware's types specifically, but if any other middleware tried to augment dispatch, those types weren't getting included
  • createActionListenerMiddleware() created and returned the actual middleware instance, but also attached the {addListener, removeListener} methods directly onto the middleware instance itself. Lenz had said earlier that he'd "solved the problem with brute force", but we determined that this somehow changed the type of the middleware enough that TS could no longer infer any dispatch augmentation.

I ended up rewriting the types for RTK's getDefaultMiddleware completely. We already had an internal MiddlewareArray type that provided custom concat/prepend methods for improved typing, since [...getDefaultMiddleware()] seemed to lose types. I was able to borrow some advanced TS type inference techniques I'd learned while working on Reselect 4.1, and upgrade its ability to extract the exact type of each middleware. I then rewrote the concat/prepend methods to return concatenated TS tuple types with the exact types of the added middleware, rather than a much looser Middleware[] array type. This preserved any potential dispatch augmentation type info.

Those changes went up as a PR for RTK itself, and I put them on an integration branch that would later become RTK 1.8.

For the middleware methods, we decided that our best option was to change the return structure of the "create middleware" function. Instead of returning the middleware directly and attaching the methods, we decided we'd have to return an object containing the instance and the methods, like {middleware, addListener, removeListener}. It was a bit annoying to have to do that just to placate TS, but we felt it was necessary to get the right typing behavior and developer usage.

v0.8: Bikeshedding Naming! 🔗︎

Faber had suggested earlier that "action listener middleware" and createActionListenerMiddleware were too long, and I agreed.

Alongside the return value changes, I proposed that we change the creation function name to createListenerMiddleware.

The other naming concern I had was that the naming clash between middleware.addListener() and the separate addListener action creator. I wanted there to be some kind of difference in the naming scheme to avoid confusion.

Lenz and I started bikeshedding some naming ideas, and I laid out a list of some possibilities:

The main questions are:

  • What should we call the object returned from the create() function?
    • Related to this, Lenz isn't keen on having the examples destructure {middleware, addListener}, and would prefer something like obj.middleware instead
  • Lenz suggested renaming the main API to createListener(), so that you have listener.middleware, etc
    • Problem is that we've been referring to the actual callback functions as "listeners', so that would require coming up with a different name for those
  • We do want to differentiate between the "instance methods" for adding and removing entries, vs the action creators

If we were to go with createListener, then some options are:

  • methods:
    • add/remove
    • addX/removeX
    • attach/detach
    • subscribe/unsubscribe
  • actions:
    • addX/removeX
    • xAdded/yRemoved
    • subscribeListener/unsubscribeListener
  • field/functions:
    • listener
    • callback
    • effect

I put out a request for feedback on Twitter, but got no response. A week later I followed up with my own preferences:

  • Keep createListenerMiddleware. It makes it clear what we're doing, and it matches the createSagaMiddleware / createEpicMiddleware naming pattern from those libraries.
  • Keep the existing listener name for the function field

That means we just need to come up with a consistent name for the object returned from createListenerMiddleware that contains {middleware, addListener, removeListener}, and settle on a name for the action creators.

One option would be to give the action creators all "past tense" names: listenerAdded, listenerRemoved, listenersCleared. That sort of aligns with the "actions as events" mindset.... except that these aren't even actions that are meant to update state, they really are direct commands to the middleware itself. So, that slightly bothers me too. But it would at least give us a naming convention to distinguish the action creators from the instance methods.

I linked the comment over in Reactiflux and mentioned it on Twitter. This time, we actually got a good discussion going in the Reactiflux #redux channel.

A couple hours of intense debate followed, with numerous naming options thrown around. We finally settled on a naming scheme that had several changes:

  • API: createListenerMiddleware
  • return object: {middleware, startListening, stopListening, clearListeners}
  • Docs examples show the object as listenerMiddleware
  • One entry: "listener"
  • Field in that listener entry: effect
  • Action creators: addListener, removeListener, removeAllListeners
const listenerMiddleware = createListenerMiddleware();

listenerMiddleware.startListening({ type, effect });
listenerMiddleware.stopListening({ type, effect });

configureStore({
  middleware: (gDM) => gDM().prepend(listenerMiddleware.middleware),
});

const unsubscribe = store.dispatch(addListener({ type, effect }));

With all those names finalized, I went ahead and made the naming and return value changes and released it as v0.8.

Finalizing the Release 🔗︎

Moving the Source 🔗︎

The listener middleware had been published as a standalone @rtk-incubator/action-listener-middleware package this whole time, but the plan was always to move it over into the actual RTK package for a final release. I had originally planned to do that in RTK 1.8, and figured that 1.8 would also include several other changes (like new RTK Query options). But, after Lenz said he was busy and wouldn't have time to work on RTKQ for a while, I decided it was best to just release 1.8 with only the new listener middleware.

I threw together a PR that moved the middleware source over into the actual RTK package folder. At the same time, Faber filed a couple more PRs to tweak remaining edge cases, like the resolved promise value of child tasks.

Last-Minute Bugs 🔗︎

I wanted to make sure that the middleware was actually getting published correctly as part of the main RTK package. I use a tool called yalc that lets me do local "publishes" of WIP packages. This gives much more consistent and realistic behavior for testing packages than trying to do npm link.

All of our unit tests were passing, but to be realistic I figured it was worth installing the local RTK build into a real project. I set up a new Vite project and copy-pasted in the source for the "counter" example that Faber had added to the RTK repo.

This turned out to be a very smart decision, because the middleware was completely broken in the example app! I could add it to the store and call listenerMiddleware.startListening(), but none of the listener effects were running when I clicked buttons.

I tried debugging with the browser devtools. Somehow it looked like the debugger was hitting the line that looped over the available listener entries... but never actually checking to see if any of them should run.

At this point I was seriously confused. Fortunately, I had another trick up my sleeve. I had recently announced that I was joining Replay.io. Replay is a true "time-traveling debugger" - it lets you record app usage, and then investigate the app's behavior and execution at any point in that recording.

I recorded a replay of the counter app behavior and opened it up. I saw the same bizarre behavior where the loop body wasn't actually getting executed. I even pulled in Jason Laster, CEO of Replay to look at it, and he saw the same thing.

I finally happened to switch from looking at the sourcemapped view of the original code over to the transpiled view of what was actually being run by the browser.... and there I saw what was going on. The middleware used a JS Map internally to keep track of listener entries, and tried to use a for..of loop to loop over those entries for conditional checks. Somehow that loop's comparison was failing, and it truly wasn't stepping into the loop body.

Fortunately, I remembered that we'd seen the same bug a year earlier, and it turned out to be an ESBuild+TS transpilation issue . The workaround was easy, albeit annoying: copy the entries into an array first, then loop over that. (You can see the replay of the actual recorded bug here.)

Final Name and Behavior Changes 🔗︎

As I was getting ready to actually publish 1.8, I noted that middleware.clearListeners() cancelled any running instances, but middleware.stopListening() and dispatch(removeListener()) did not. I updated those to accept an optional {cancelActive?: true} parameter to enable cancellation upon removal.

Finally, Faber pointed out that the removeAllListeners action creator should be more distinct and consistent with the instance methods, so we renamed it to clearAllListeners instead.

And with that, I finally released the listener middleware live in RTK v1.8.0!.

Conclusions 🔗︎

When I filed the original issue in 2019, I had no idea that the process would take over two years, or that we'd end up effectively replacing sagas :) But, all the time, effort, iteration, and bikeshedding paid off, and I'm very happy with the final result.

Reception 🔗︎

I announced the release of RTK 1.8 on February 27 and also linked the release notes on Reddit. To be honest I actually didn't expect it to be that big a deal - I figured this was more of a niche API to begin with, so I wasn't expecting a big response.

To my very pleasant surprise, we've already gotten a bunch of very positive feedback. Some examples:

  • "I just cobbled up something that will replace a complete set of sagas and all their associated r-r boilerplate with a single small listener middleware. Ridiculously easy!"
  • "This is great 🙌. Listening to state changes is something I've been missing from thunks / sagas. If your actions are event-based and business logic is in the reducer, listening to actions is not enough."
  • "I literally am working on an app with where I need this feature (since I'm using RTK), I check my phone and I see this update. Mind blown. I will give this a shot."
  • "Quick shout out to you and your fellow RTK devs! I've just implemented the new listener middleware for a feature that persists specific slices of redux to indexedDB via Dexie, and it was practically painless. Listen for changes to the slice in a prev/next predicate, ignore it if it's the rehydrate action, fire a side effect that updates the changed property in indexedDB, and... it just works. The docs are clear and concise as usual, the syntax is easy to grasp, and it's easily as powerful as sagas or rxjs epics (at least how I've seen them used) without hardly any of the cognitive overhead. With this on top of RTK Query, I can finally get rid of all the clunky hard-to-test observables in this app and the weird actions firing actions firing actions spaghetti that's been built up over the last few years. Seriously, thank you so much. You've all made my job a lot easier."

This is extremely exciting as a maintainer - it tells me that the new API is solving the right use cases, and that the time and effort we put into it was worth it.

Key API Design Moments 🔗︎

Looking back, there were several key ideas and sources of inspiration that led to the final design:

  • The original vision of "provide callbacks that run in response to specific actions" was solid
  • "Add listeners at runtime with dispatch(addListener())" was also there at the beginning and was a key part of the design the entire way through
  • I had the idea for the (action, currState, prevState) => boolean "predicate" option right away, which turned out to enable running logic in response to state changes and not just actions
  • Lenz's second PR ultimately formed the basis of the rest of the implementation
  • The "run listeners before or after?" discussion led to adding getOriginalState, as well as the subscribe/unsubscribe methods
  • My interest in using the listener middleware in my work app prompted me to add matcher as an option, as well as extracting the existing PR code to work standalone and restarting the development work
  • Faber argued for dropping stopPropagation and when, which simplified the design, and also came up with all the error handling concepts
  • Shawn Swyx Wang suggested adding condition based on Temporal's API (and amusingly my implementation of condition(predicate, timeout?) prompted Temporal to rearrange their function's arguments order to match ours)
  • That led to me thinking about expanding the scope of the middleware to try to match saga capabilities, and investigating topics like promise cancellation and child task support
  • I had the idea for changing from positional parameters in addListener to an options object, but Lenz Weber and Josh DeGraw came up with the TS solutions that unblocked my attempt to rewrite the types
  • Faber suggested take and middleware.clearListeners, and rewrote the initial "jobs" API to provide similar cancellation behavior based on AbortController
  • Lenz and I investigated the const unsubscribe = dispatch(addListener()) TS issues. I was able to leverage my experience from Reselect 4.1 to rewrite the MiddlewareArray and getDefaultMiddleware types.
  • Multiple people participated in the final name bikeshedding discussion
  • I noted the lack of cancel-on-remove right at the end

Clearly, this was a very iterative process! The end result definitely fulfilled my original vision, but it was much more comprehensive than I could ever have imagined at the start.

Lessons and Takeaways 🔗︎

Hopefully this gives some insight into some of the process for designing a new library API.

I think there's several lessons that library maintainers can take away from this process:

  • Have a clear vision and purpose for the API: From the very beginning, I had a specific goal that I wanted this new API to accomplish. I saw a gap in RTK's functionality, and I specifically wanted to fill that gap. Additionally, while user feedback was absolutely critical (as discussed below), it was also important to keep the goals and implementation scoped and on track.
  • Design and discuss in public: Lenz and I did the bulk of the implementation work. However, many of the key design decisions only happened because of specific discussions with interested community members, as they described potential use cases and offered suggestions for API design and behavior. There's no way Lenz and I could have worked out the final design on our own - that back-and-forth discussion was critical, and that was only possible because we did the entire process in public from day 1. (That's not to say that internal discussions or design work are bad / should be avoided, but the more you can get the community involved, the better.)
  • Running code encourages discussion: Having a couple prototype PRs was critical. They gave us running examples to try out, and concrete code implementations to review and critique.
  • Get code in user hands ASAP, and iterate: Early on, having CodeSandbox CI set up to publish installable RTK packages from the PR branches made it possible for people to try out the initial builds. Later on, the use of a separate "incubator" package plus an ongoing Github Discussions thread helped us iterate on development and nail down the final behavior.
  • Tests and example use cases are valuable: The unit tests for the middleware served multiple purposes. Besides actual code coverage, they acted as "type tests" to verify the TS compilation behavior. We also added tests that demonstrated specific use cases, like implementing various Redux-Saga effects and some other example usage scenarios. This helped us verify that the API could do what we wanted, and gave us ideas for how to tweak things. (And in the case of the final pre-release checks, checking behavior in a real app helped me find a critical transpilation/publish bug.)

Of course other tools and communities will have different experiences. I can actually point to two other fairly contrasting experiences with React-Redux:

As always, YMMV, but I think these principles can be useful for library maintainers that are looking to design new APIs.


This is a post in the Idiomatic Redux series. Other posts in this series: