New experimental "action listener middleware" package available #1648
Replies: 37 comments 115 replies
-
Hi, For example:
|
Beta Was this translation helpful? Give feedback.
-
We have been using similar functionality for a long time |
Beta Was this translation helpful? Give feedback.
-
Action Listener Middleware NotesI've just gone through all the prior issues and PR discussions about this middleware implementation and pulled out links and summaries of any points that looked interesting or worth further discussion. I'll do a follow-up pass on this list tomorrow and summarize the summaries into a list of bullet points that I think we ought to nail down. Issue #237
PR #547
Other discussions
Other thoughts
|
Beta Was this translation helpful? Give feedback.
-
stopPropagationSeldom a react developer needs to worry about the order of scheduled
Conclusion
|
Beta Was this translation helpful? Give feedback.
-
Changelogv0.2.0I implemented most of the fixes I had listed in #1648 (reply in thread) , and I updated the README with much more extensive usage information and pasted all that content into the main post up top. https://www.npmjs.com/package/@rtk-incubator/action-listener-middleware npm i @rtk-incubator/action-listener-middleware@0.2.0
yarn add @rtk-incubator/action-listener-middleware@0.2.0 Changes per #1706 :
I did realize that the The really big news here is that the middleware now has support for longer-running async workflows via the const checkSucceeded = await condition(listenerPredicate, timeout?); Returns That should allow behavior like "sleep until some other action is dispatched". With that in place, I think this is now really a viable lightweight alternative to sagas and observables! |
Beta Was this translation helpful? Give feedback.
-
Just a minor note: if the Redux style guide tells us that the actions should actually be events, not setters, don't you want to try to name them properly? I tried to find the solution, but I found nothing better than |
Beta Was this translation helpful? Give feedback.
-
One general addition: this is not trying to replace saga. This does not need to cover every nook and cranny and weird use case. This is meant to fill a gap that thunks can't fill and where saga is still oversized. Please let's not overengineer this thing, but keep it simple. |
Beta Was this translation helpful? Give feedback.
-
What is the supposed way if several handlers should be added as a piece of a single module? To make it clear: const listenToLogin = (action) => localStorage.setItem('token', action.payload.token)
const listenToLogout = (action) => localStorage.removeItem('token') It's a very rough example, but in my experience, there's a sense to group some handlers together. If I get it correct, with the current API we have either the option to export an array of matcher-listener pairs and iterate through them or to do this: addListener(anyOf(loggedIn, loggedOut), (action) => {
if (action.matches(loggedIn)) {
localStorage.setItem('token', action.payload.token)
} else /* if action matches(loggedOut) */ {
localStorage.removeItem('token')
}
}) But the latter looks awkward to me I might be wrong with the interfaces, so it's more like pseudocode, but the question is: what is the supposed way of grouping the listeners (not as dependents, but more as neighbours)? Do we really want to keep the action/listener coupling at the top of the middleware API, or maybe it may be moved lower? I always thought of it more like of a reducer-ish type of interface |
Beta Was this translation helpful? Give feedback.
-
I built a "Tweet Undo" feature with the ScreenFlow.mp4 |
Beta Was this translation helpful? Give feedback.
-
I've spent the last couple days attempting to rewrite both the TS types and the code structure so that:
So far I'm failing. I've started and stopped a couple dozen attempts at the types. A couple came close but couldn't quite get the inference right. The current WIP branch is here: https://github.com/reduxjs/redux-toolkit/commits/feature/listener-middleware-fixes If anyone has ideas on how to make this work the way I want, I'd really appreciate help here! |
Beta Was this translation helpful? Give feedback.
-
I kinda wanted to have a single "Changelog" subthread, but I think the continuing updates will get buried because Github collapses subthreads after a handful of replies. So, one top-level reply per meaningful release, I guess? (You can tell we haven't figured out this monorepo thing yet) v0.3.0ChangelogThis release completely rewrites the TS types and logic so that npm i @rtk-incubator/action-listener-middleware@0.3.0
yarn add @rtk-incubator/action-listener-middleware@0.3.0 [BREAKING] Add Listener Options Changes0.2.0 had a couple major issues with adding listeners. The I knew what I wanted the API and types behavior to look like, but I ran into a wall trying to get them to cooperate. Happily, @josh-degraw and @phryneas both tried tackling the problem, and came up with the puzzle pieces I needed. Josh showed how to properly extract the type overloads to be reusable, and Lenz showed an approach for passing an options object with varying fields that could be type-inferred correctly. So, both type AddListenerOptions = {
type?: string
actionCreator?: TypedActionCreator
matcher?: Matcher
predicate?: ListenerPredicate
listener: ActionListener
when?: When
} You must provide one of the first four fields to tell the middleware how to know when the listener needs to run. Any actual usages of the middleware and Typing ListenersThe Instead, the middleware now includes a pair of types for defining "pre-typed" versions of the The TS type of the import {
createActionListenerMiddleware,
TypedAddListener,
TypedAddListenerAction,
} from '@rtk-incubator/action-listener-middleware';
import { incrementByAmount } from '../features/counter/counterSlice';
import type { AppState } from './store';
export const listenerMiddleware = createActionListenerMiddleware();
// Declare and export pre-typed versions of these functions
export const addAppListener: TypedAddListener<AppState> =
listenerMiddleware.addListener;
export const addAppListenerAction: TypedAddListenerAction<AppState> =
listenerMiddleware.addListenerAction;
//
addAppListener({
// If passing a `predicate`, declare each param's type explicitly,
// and correct action inference requires the type predicate assertion too
predicate: (
action: AnyAction,
currentState: CounterState,
originalState: CounterState
): action is PayloadAction<number> => {
// The types of both `currentState/originalState` should be from the pre-typed version
expectNotAny(currentState)
expectExactType<CounterState>(currentState)
return true
},
listener: (action, listenerApi) => {
// If using `action`, `type`, `matcher`, or a `predicate` that has the right
// declarations, the type of `action` should be inferred
expectType<PayloadAction<number>>(action)
// The `state` type should carry through to `listenerApi`
const listenerState = listenerApi.getState()
expectExactType<CounterState>(listenerState)
// Dispatching thunks should work okay by default
listenerApi.dispatch((dispatch, getState) => {
// And the state type should carry through to thunks as well
const thunkState = listenerApi.getState()
expectExactType<CounterState>(thunkState)
})
},
}) Package FixesThe 0.2.0 package tried to use Error Handling
|
Beta Was this translation helpful? Give feedback.
-
I've created a simple counter example that uses action-listener-middleware that uses condition and subscribes 2 listeners. |
Beta Was this translation helpful? Give feedback.
-
Been doing some more brainstorming with regards to API design and capabilities, particularly around task cancellation and error handling.
Looking through the saga docs ( https://redux-saga.js.org/docs/basics/DeclarativeEffects and onwards ), I think we can mimic a decent portion of what can be done with sagas. The listener already acts like a The biggest weakness with Besides sagas, there's three other major side effects approaches that I can think of that might do something like that: observables, state machines, and https://github.com/jeffbski/redux-logic:
There's also http://ember-concurrency.com , which is similar to sagas in that it uses generator functions. (Plus I'm seeing a bunch of other JS libs that implement more complex async logic with generators, like https://github.com/getify/CAF, https://github.com/thefrontside/effection , https://github.com/jetako/mst-async-task, https://github.com/CygnusRoboticus/concurrency-light, etc). Another thing that sagas and Searching around through NPM for things like
It would be interesting to see if one of those could be adapted here. I will say that https://github.com/jeffbski/redux-logic does have a very interesting API, but A) the lib seems to be semi-dead at this point, B) has a huge bundle size due to use of There's also a prior related RTK issue thread on "declarative side effects and data fetching", which has discussed things like state machines. Temporal.io has some interesting and relevant API design also: https://docs.temporal.io/docs/typescript/cancellation-scopes/ , https://docs.temporal.io/docs/typescript/workflows/#workflow-apis . No real conclusions atm, other than "canceling async functions is clearly not easily doable - if you really want that you need generators". |
Beta Was this translation helpful? Give feedback.
-
Sorry for the delay :( have been swamped all morning. Here are a few use cases of takeLatest in our codebase. I did a lot of soul searching when looking back at these and realize that have a) implemented it another way that is arguably better or b) convert over to RTK query. Regardless, it is nice to have a drop in replacement for saga to ease transition to RTK or for people that don't want to use RTK query for data fetching and instead do things manually. In all cases I personally would opt to use RTK query. Also worth noting that RTK query didn't exist at the time of writing any of this code.
This example is fetching autocomplete suggestions as you type. In the future I can just implement debounce inside of the map autocomplete searchbox component itself. I could take advantage of
In hindsight implementing auth ourselves is a fools chore. We do have our own auth server that serves as a “proxy” to Auth0 so that certain things are completely customized. It would be cool to instead use a solution like next-auth. Unfortunately, in my experience with a personal project next-auth still isn’t 100% viable for our needs (but is rapidly improving). We would also need to transition all of our apps to Next which is a very heavy lift with not many tangible business value compared to other tickets. To make matters more complicated our backend really isn't even really a legit Oauth server, more of a proxy so next-auth potentially wouldn't even be what to reach for. RTK query would probably be the more appropriate thing to reach for in this scenario. One part of this that we absolutely needed was the “inactivity check”. Our application is used by government customers with sensitive data. It is required that we “kick them off” after a certain period of inactivity (similar to what banking sites do). In hindsight file could have used the useIdle hook from react-use in our App.tsx and dispatched logout from there. Ideally I want to reimplement this flow in RTK Query in the future in conjunction with the useIdle hook as mentioned above.
The company I work for makes an interactive data analysis tool that data analysts use (similar to Tableau). The analysis tool UI contains a summary panel containing a summary of current sub selections made, an interactive timeline, a data table, and a map. All of these components show the same data simultaneously but presented in different ways. A feature that the analysts absolutely love is being able to hover over a device identifier in the timeline plot/table/map/summary panel and have that row of data “emphasized”/highlighted on all other components (currently hovered device identifier is referred to as "highlightedDeviceIdentifier" in Redux). We also have a longHoveredHighlightedDeviceIdentifier which is debounced. Our primary use for this is that the Plot library we use gets really slow when a ton of rerenders occur. We fixed this issue by only feeding the plot library the longHoveredDeviceIdentifier instead of hoveredDeviceIndentifier to minimize these expensive rerenders. We very well could have implemented debounce for the “longHoveredDeviceIdentifier” on an individual component basis but it is much more ergonomic to have this handled at a global level. |
Beta Was this translation helpful? Give feedback.
-
v0.4.0This release adds a new I've updated the API reference in the main post of this thread to cover this version. ChangelogNew
|
Beta Was this translation helpful? Give feedback.
-
In I immediately felt like this was weirdly inconsistent with other redux apis where the context/state/store is passed as the first argument and action second. e.g. reducers are Is there a reason why the argument order is action first, store/context/api second? |
Beta Was this translation helpful? Give feedback.
-
What would be the equivalent of using an "actionChannel" in redux saga, is that achievable with listeners atm? The user might open a single node (the most common scenario), or they might open many nodes. Every opened node will try to load their details data. There are other ways I can model this of course, but just wondering if there was a recipe already for something like this using a listener. |
Beta Was this translation helpful? Give feedback.
-
bug report: currently if you follow the instructions for setting up typescript even though the return value is actually the unsubscribe function |
Beta Was this translation helpful? Give feedback.
-
Memory pressure caused by
|
Beta Was this translation helpful? Give feedback.
-
Found a great use case for this middleware: Google Maps places API result fetching. Google forces you to use their JS library instead of HTTP endpoints which means RTK query can't be used. Will be digging in deep during coming week and excited to run the middleware through its paces :D Are things looking promising for this middleware in terms of it making it into RTK at some point (with the exception of potential API changes)? |
Beta Was this translation helpful? Give feedback.
-
The author of https://github.com/neurosnap/robodux (which is a similar library inspired by RTK but taken in a different direction API-wise) just posted that they've been creating an "Express-style middleware" setup: https://www.reddit.com/r/reactjs/comments/s1ubrx/robodux_a_powerful_middleware_and_caching_library/ Looks like the actual code is here: Seeing a few bits of similarity in there, especially the use of cancelation and edit Looks like the author just extracted that code into its own repo: |
Beta Was this translation helpful? Give feedback.
-
Is there a way to forcibly remove all listeners? e.g. to reliably clean up listeners between tests Something like: listenerMiddleware.removeAllListeners = (): void => {
listenerMap.clear()
} |
Beta Was this translation helpful? Give feedback.
-
v0.7.0This release adds a ChangelogListener Removal ImprovementsThe middleware instance now has a The Also, the
|
Beta Was this translation helpful? Give feedback.
-
Middleware Status Update and Proposed Changes - 2022-02-05At this point I'm satisfied with the overall listener middleware API. There's two outstanding TS issues that need to be fixed:
I've already fixed the Lenz and I have been investigating the " However, the flip side is that modifying the middleware to attach While getting the type of Lenz suggested that another option might be to have the Somewhat related: Faber suggested somewhere around here that we should shorten the name (although I can't find where that was). Based on all that, I propose to:
I'm going to give that a shot see how it turns out. |
Beta Was this translation helpful? Give feedback.
-
Name Bikeshedding Time!Following on from the previous subthread, Lenz and I started bikeshedding renaming basically everything. The main questions are:
If we were to go with
I already have a PR up at #2005 to rename to Thoughts? |
Beta Was this translation helpful? Give feedback.
-
v0.8.0This release contains significant breaking changes from API renames and signature changes! However, this should be the final standalone version before we release this in an upcoming RTK 1.8 build. ChangelogAPI RenamingNow that the feature set and behavior is complete, we did a review and extensive discussion to determine final naming for all the APIs. The goals were:
After much bikeshedding, we settled on these final names and terms:
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})) This release includes all of those name changes.
|
Beta Was this translation helpful? Give feedback.
-
A user on Reddit brought up the question of "how does this middleware relate to SSR, if at all?", and listed examples of sagas and observables having to deal with "handling endless streams of actions" and "notifying React that you're done". Not sure there's anything we could / should do here, but interesting question: https://www.reddit.com/r/reactjs/comments/sumbjo/how_are_you_using_redux/hxbv0g1/?context=3 |
Beta Was this translation helpful? Give feedback.
-
Would this be an appropriate place to establish a Something better than your own example in here: https://gist.github.com/markerikson/3df1cf5abbac57820a20059287b4be58 |
Beta Was this translation helpful? Give feedback.
-
Could you please provide more examples regarding the removal of a listener? I'm implementing the Also, is there any way to get the list of all active listeners? |
Beta Was this translation helpful? Give feedback.
-
A fork misses actions that were dispatched immediately after its creation, but a listener doesn't. It means that if I want to guarantee that I don't miss any actions, I have to imitate forks by adding a listener and triggering it instantly. (a) Is this intended? (b) Is this a good workaround? (c) Are there any guarantees for forks and listeners wrt to missing some actions? CodeSandbox Example. Click "Test", then look at the console,
|
Beta Was this translation helpful? Give feedback.
-
Update, 2022-02-08: Listener Middleware Released in RTK 1.8!
The listener middleware is now available in RTK 1.8! Thanks to everyone who helped try out the alpha versions and gave us feedback.
Previously
This discussion is for feedback and API design of our experimental "listener middleware" package:
https://www.npmjs.com/package/@rtk-incubator/action-listener-middleware
Status
As of 2022-02-12, we've published v0.8.0 of the standalone middleware package. It contains major breaking changes due to API renames and return value changes, but the API should now be finished.The plan is to release the middleware as part of RTK 1.8:
Don't have an ETA for that release yet, but hopefully fairly soon.
Overview
I did a livestream where I described the purpose of this middleware and dug into the source code:
Pasting the actual description from the README:
This package provides a callback-based Redux middleware that we plan to include in Redux Toolkit directly in the next feature release. We're publishing it as a standalone package to allow users to try it out separately and give us feedback on its API design.
This middleware lets you define "listener" entries containing "effect" callbacks that will run in response to specific actions being dispatched. It's intended to be a lightweight alternative to more widely used Redux async middleware like sagas and observables. While similar to thunks in level of complexity and concept, it can be used to replicate some common saga usage patterns.
Conceptually, you can think of this as being similar to React's
useEffect
hook, except that it runs logic in response to Redux store updates instead of component props/state updates.Basic Usage
Motivation
The Redux community has settled around three primary side effects libraries over time:
dispatch
. They let users run arbitrary logic, including dispatching actions and getting state. These are mostly used for basic AJAX requests and logic that needs to read from state before dispatching actionsAll three of those have strengths and weaknesses:
redux-saga
's effects API, and are overkill for many simpler use casesIf you need to run some code in response to a specific action being dispatched, you could write a custom middleware:
However, it would be nice to have a more structured API to help abstract this process.
The
createListenerMiddleware
API provides that structure.For more background and debate over the use cases and API design, see the original discussion issue and PR:
API Reference
createListenerMiddleware
lets you add listeners by providing an "effect callback" containing additional logic, and a way to specify when that callback should run based on dispatched actions or state changes.The middleware then gives you access to
dispatch
andgetState
for use in your effect callback's logic, similar to thunks. The listener also receives a set of async workflow functions liketake
,condition
,pause
,fork
, andunsubscribe
, which allow writing more complex async logic.Listeners can be defined statically by calling
listenerMiddleware.startListening()
during setup, or added and removed dynamically at runtime with specialdispatch(addListener())
anddispatch(removeListener())
actions.createListenerMiddleware: (options?: CreateMiddlewareOptions) => ListenerMiddlewareInstance
Creates an instance of the middleware, which should then be added to the store via
configureStore
'smiddleware
parameter.Current options are:
extra
: an optional "extra argument" that will be injected into thelistenerApi
parameter of each listener. Equivalent to the "extra argument" in the Redux Thunk middleware.onError
: an optional error handler that gets called with synchronous and async errors raised bylistener
and synchronous errors thrown bypredicate
.createListenerMiddleware
returns an object (similar to howcreateSlice
does), with the following fields:middleware
: the actual listener middleware instance. Add this toconfigureStore()
startListening
: adds a single listener entry to this specific middleware instancestopListening
: removes a single listener entry from this specific middleware instanceclearListeners
: removes all listener entries from this specific middleware instancestartListening(options: AddListenerOptions) : Unsubscribe
Statically adds a new listener entry to the middleware.
The available options are:
You must provide exactly one of the four options for deciding when the listener will run:
type
,actionCreator
,matcher
, orpredicate
. Every time an action is dispatched, each listener will be checked to see if it should run based on the current action vs the comparison option provided.These are all acceptable:
Note that the
predicate
option actually allows matching solely against state-related checks, such as "didstate.x
change" or "the current value ofstate.x
matches some criteria", regardless of the actual action.The "matcher" utility functions included in RTK are acceptable as predicates.
The return value is a standard
unsubscribe()
callback that will remove this listener. If you try to add a listener entry but another entry with this exact function reference already exists, no new entry will be added, and the existingunsubscribe
method will be returned.The
effect
callback will receive the current action as its first argument, as well as a "listener API" object similar to the "thunk API" object increateAsyncThunk
.All listener predicates and callbacks are checked after the root reducer has already processed the action and updated the state. The
listenerApi.getOriginalState()
method can be used to get the state value that existed before the action that triggered this listener was processed.stopListening(options: AddListenerOptions): boolean
Removes a given listener. It accepts the same arguments as
startListening()
. It checks for an existing listener entry by comparing the function references oflistener
and the providedactionCreator/matcher/predicate
function ortype
string.Returns
true
if theoptions.effect
listener has been removed, orfalse
if no subscription matching the input provided has been found.clearListeners(): void
Removes all current listener entries. This is most likely useful for test scenarios where a single middleware or store instance might be used in multiple tests, as well as some app cleanup situations.
addListener
A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically add a new listener at runtime. It accepts exactly the same options as
startListening()
Dispatching this action returns an
unsubscribe()
callback fromdispatch
.removeListener
A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove a listener at runtime. Accepts the same arguments as
stopListening()
.Returns
true
if theoptions.listener
listener has been removed,false
if no subscription matching the input provided has been found.removeAllListeners
A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove all listeners at runtime.
listenerApi
The
listenerApi
object is the second argument to each listener callback. It contains several utility functions that may be called anywhere inside the listener's logic. These can be divided into several categories:Store Interaction Methods
dispatch: Dispatch
: the standardstore.dispatch
methodgetState: () => State
: the standardstore.getState
methodgetOriginalState: () => State
: returns the store state as it existed when the action was originally dispatched, before the reducers ran. (Note: this method can only be called synchronously, during the initial dispatch call stack, to avoid memory leaks. Calling it asynchronously will throw an error.)dispatch
andgetState
are exactly the same as in a thunk.getOriginalState
can be used to compare the original state before the listener was started.Middleware Options
extra: unknown
: the "extra argument" that was provided as part of the middleware setup, if anyextra
can be used to inject a value such as an API service layer into the middleware at creation time, and is accessible here.Listener Subscription Management
unsubscribe: () => void
: will remove the listener from the middlewaresubscribe: () => void
: will re-subscribe the listener if it was previously removed, or no-op if currently subscribedcancelActiveListeners: () => void
: cancels all other running instances of this same listener except for the one that made this call. (The cancelation will only have a meaningful effect if the other instances are paused using one of the cancelation-aware APIs liketake/cancel/pause/delay
- see "Cancelation and Task Management" in the "Usage" section for more details)signal: AbortSignal
: AnAbortSignal
whoseaborted
property will be set totrue
if the listener execution is aborted or completed.Dynamically unsubscribing and re-subscribing this listener allows for more complex async workflows, such as avoiding duplicate running instances by calling
listenerApi.unsubscribe()
at the start of a listener, or callinglistenerApi.cancelActiveListeners()
to ensure that only the most recent instance is allowed to complete.Conditional Workflow Execution
take: (predicate: ListenerPredicate, timeout?: number) => Promise<[Action, State, State] | null>
: returns a promise that will resolve when thepredicate
returnstrue
. The return value is the[action, currentState, previousState]
combination that the predicate saw as arguments. If atimeout
is provided and expires first, the promise resolves tonull
.condition: (predicate: ListenerPredicate, timeout?: number) => Promise<boolean>
: Similar totake
, but resolves totrue
if the predicate succeeds, andfalse
if atimeout
is provided and expires first. This allows async logic to pause and wait for some condition to occur before continuing. See "Writing Async Workflows" below for details on usage.delay: (timeoutMs: number) => Promise<void>
: returns a cancelation-aware promise that resolves after the timeout, or rejects if canceled before the expirationpause: (promise: Promise<T>) => Promise<T>
: accepts any promise, and returns a cancelation-aware promise that either resolves with the argument promise or rejects if canceled before the resolutionThese methods provide the ability to write conditional logic based on future dispatched actions and state changes. Both also accept an optional
timeout
in milliseconds.take
resolves to a[action, currentState, previousState]
tuple ornull
if it timed out, whereascondition
resolves totrue
if it succeeded orfalse
if timed out.take
is meant for "wait for an action and get its contents", whilecondition
is meant for checks likeif (await condition(predicate))
.Both these methods are cancelation-aware, and will throw a
TaskAbortError
if the listener instance is canceled while paused.Child Tasks
fork: (executor: (forkApi: ForkApi) => T | Promise<T>) => ForkedTask<T>
: Launches a "child task" that may be used to accomplish additional work. Accepts any sync or async function as its argument, and returns a{result, cancel}
object that can be used to check the final status and return value of the child task, or cancel it while in-progress.Child tasks can be launched, and waited on to collect their return values. The provided
executor
function will be called with aforkApi
object containing{pause, delay, signal}
, allowing it to pause or check cancelation status. It can also make use of thelistenerApi
from the listener's scope.An example of this might be a listener that forks a child task containing an infinite loop that listens for events from a server. The parent then uses
listenerApi.condition()
to wait for a "stop" action, and cancels the child task.The task and result types are:
Usage Guide
Overall Purpose
This middleware lets you run additional logic when some action is dispatched, as a lighter-weight alternative to middleware like sagas and observables that have both a heavy runtime bundle cost and a large conceptual overhead.
This middleware is not intended to handle all possible use cases. Like thunks, it provides you with a basic set of primitives (including access to
dispatch
andgetState
), and gives you freedom to write any sync or async logic you want. This is both a strength (you can do anything!) and a weakness (you can do anything, with no guard rails!).As of v0.5.0, the middleware does include several async workflow primitives that are sufficient to write equivalents to many Redux-Saga effects operators like
takeLatest
,takeLeading
, anddebounce
.Standard Usage Patterns
The most common expected usage is "run some logic after a given action was dispatched". For example, you could set up a simple analytics tracker by looking for certain actions and sending extracted data to the server, including pulling user details from the store:
However, the
predicate
option also allows triggering logic when some state value has changed, or when the state matches a particular condition:You could also implement a generic API fetching capability, where the UI dispatches a plain action describing the type of resource to be requested, and the middleware automatically fetches it and dispatches a result action:
The
listenerApi.unsubscribe
method may be used at any time, and will remove the listener from handling any future actions. As an example, you could create a one-shot listener by unconditionally callingunsubscribe()
in the body - it would run the first time the relevant action is seen, and then immediately stop and not handle any future actions. (The middleware actually uses this technique internally for thetake/condition
methods)Writing Async Workflows with Conditions
One of the great strengths of both sagas and observables is their support for complex async workflows, including stopping and starting behavior based on specific dispatched actions. However, the weakness is that both require mastering a complex API with many unique operators (effects methods like
call()
andfork()
for sagas, RxJS operators for observables), and both add a significant amount to application bundle size.While the listener middleware is not meant to fully replace sagas or observables, it does provide a carefully chosen set of APIs to implement long-running async workflows as well.
Listeners can use the
condition
andtake
methods inlistenerApi
to wait until some action is dispatched or state check is met. Thecondition
method is directly inspired by thecondition
function in Temporal.io's workflow API (credit to @swyx for the suggestion!), andtake
is inspired by thetake
effect from Redux-Saga.The signatures are:
You can use
await condition(somePredicate)
as a way to pause execution of your listener callback until some criteria is met.The
predicate
will be called before and after every action is processed, and should returntrue
when the condition should resolve. (It is effectively a one-shot listener itself.) If atimeout
number (in ms) is provided, the promise will resolvetrue
if thepredicate
returns first, orfalse
if the timeout expires. This allows you to write comparisons likeif (await condition(predicate))
.This should enable writing longer-running workflows with more complex async logic, such as the "cancellable counter" example from Redux-Saga.
An example of usage, from the test suite:
Cancelation and Task Management
As of 0.5.0, the middleware now supports cancelation of running listener instances,
take/condition/pause/delay
functions, and "child tasks", with an implementation based onAbortController
.The
listenerApi.pause/delay()
functions provide a cancelation-aware way to have the current listener sleep.pause()
accepts a promise, whiledelay
accepts a timeout value. If the listener is canceled while waiting, aTaskAbortError
will be thrown. In addition, bothtake
andcondition
support cancelation interruption as well.listenerApi.fork()
can used to launch "child tasks" that can do additional work. These can be waited on to collect their results. An example of this might look like:Complex Async Workflows
The provided async workflow primitives (
cancelActiveListeners
,unsuscribe
,subscribe
,take
,condition
,pause
,delay
) can be used to implement many of the more complex async workflow capabilities found in the Redux-Saga library. This includes effects such asthrottle
,debounce
,takeLatest
,takeLeading
, andfork/join
. Some examples:TypeScript Usage
The code is fully typed. However, the
startListening
andaddListener
functions do not know what the store'sRootState
type looks like by default, sogetState()
will returnunknown
.To fix this, the middleware provides types for defining "pre-typed" versions of those methods, similar to the pattern used for defing pre-typed React-Redux hooks:
Then import and use those pre-typed versions in your components.
Feedback
Please provide feedback in RTK discussion #1648: "New experimental "action listener middleware" package".
Beta Was this translation helpful? Give feedback.
All reactions