Skip to content

Commit 22418fe

Browse files
committed
Expand listener middleware README
1 parent 3b3a562 commit 22418fe

File tree

1 file changed

+182
-8
lines changed

1 file changed

+182
-8
lines changed

packages/action-listener-middleware/README.md

+182-8
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,16 @@ import todosReducer, {
2727
// Create the middleware instance
2828
const listenerMiddleware = createActionListenerMiddleware()
2929

30-
// Add one or more listener callbacks for specific actions
30+
// Add one or more listener callbacks for specific actions. They may
31+
// contain any sync or async logic, similar to thunks.
3132
listenerMiddleware.addListener(todoAdded, (action, listenerApi) => {
3233
// Run whatever additional side-effect-y logic you want here
3334
const { text } = action.payload
3435
console.log('Todo added: ', text)
3536

3637
if (text === 'Buy milk') {
3738
// Use the listener API methods to dispatch, get state, or unsubscribe the listener
39+
listenerApi.dispatch(todoAdded('Buy pet food'))
3840
listenerApi.unsubscribe()
3941
}
4042
})
@@ -84,34 +86,88 @@ For more background and debate over the use cases and API design, see the origin
8486
- [RTK issue #237: Add an action listener middleware](https://github.com/reduxjs/redux-toolkit/issues/237)
8587
- [RTK PR #547: yet another attempt at an action listener middleware](https://github.com/reduxjs/redux-toolkit/pull/547)
8688

87-
## Usage and API
89+
## API Reference
8890

8991
`createActionListenerMiddleware` lets you add listeners by providing an action type and a callback, lets you specify whether your callback should run before or after the action is processed by the reducers, and gives you access to `dispatch` and `getState` for use in your logic. Callbacks can also unsubscribe.
9092

9193
Listeners can be defined statically by calling `listenerMiddleware.addListener()` during setup, or added and removed dynamically at runtime with special `dispatch(addListenerAction())` and `dispatch(removeListenerAction())` actions.
9294

93-
### `createActionListenerMiddleware`
95+
### `createActionListenerMiddleware: (options?: CreateMiddlewareOptions) => Middleware`
9496

9597
Creates an instance of the middleware, which should then be added to the store via the `middleware` parameter.
9698

97-
### `listenerMiddleware.addListener(actionType, listener, options?) : Unsubscribe`
99+
Current options are:
100+
101+
- `extra`: an optional "extra argument" that will be injected into the `listenerApi` parameter of each listener. Equivalent to [the "extra argument" in the Redux Thunk middleware](https://redux.js.org/usage/writing-logic-thunks#injecting-config-values-into-thunks).
102+
103+
### `listenerMiddleware.addListener(predicate, listener, options?) : Unsubscribe`
98104

99105
Statically adds a new listener callback to the middleware.
100106

101107
Parameters:
102108

103-
- `actionType: string | ActionCreator | Matcher`: Determines which action(s) will cause the `listener` callback to run. May be a plain action type string, a standard RTK-generated action creator with a `.type` field, or an RTK "matcher" function. The listener will be run if the current action's `action.type` string is an exact match, or if the matcher function returns true.
104-
- `listener: (action: Action, listenerApi: ListenerApi) => void`: the listener callback. Will receive the current action as its first argument. The second argument is a "listener API" object similar to the "thunk API" object in `createAsyncThunk`. It contains the usual `dispatch` and `getState` store methods, as well as two listener-specific methods: `unsubscribe` will remove the listener from the middleware, and `stopPropagation` will prevent any further listeners from handling this specific action.
109+
- `predicate: string | ActionCreator | Matcher | ListenerPredicate`: Determines which action(s) will cause the `listener` callback to run. May be a plain action type string, a standard RTK-generated action creator with a `.type` field, an RTK "matcher" function, or a "listener predicate" that also receives the current and original state. The listener will be run if the current action's `action.type` string is an exact match, or if the matcher/predicate function returns `true`.
110+
- `listener: (action: Action, listenerApi: ListenerApi) => void`: the listener callback. Will receive the current action as its first argument, as well as a "listener API" object similar to the "thunk API" object in `createAsyncThunk`. It contains:
111+
- `dispatch: Dispatch`: the standard `store.dispatch` method
112+
- `getState: () => State`: the standard `store.getState` method
113+
- `getOriginalState: () => State`: returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran
114+
- `currentPhase: 'beforeReducer' | 'afterReducer'`: an string indicating when the listener is being called relative to the action processing
115+
- `condition: (predicate: ListenerPredicate, timeout?) => Promise<boolean>`: allows async logic to pause and wait for some condition to occur before continuing. See "Writing Async Workflows" below for details on usage.
116+
- `extra`: the "extra argument" that was provided as part of the middleware setup, if any
117+
- `unsubscribe` will remove the listener from the middleware
105118
- `options: {when?: 'before' | 'after'}`: an options object. Currently only one options field is accepted - an enum indicating whether to run this listener 'before' the action is processed by the reducers, or 'after'. If not provided, the default is 'after'.
106119

107-
The return value is a standard `unsubscribe()` callback that will remove this listener.
120+
The return value is a standard `unsubscribe()` callback that will remove this listener. If a listener entry with this exact function reference already exists, no new entry will be added, and the existing `unsubscribe` method will be returned.
121+
122+
Adding a listener takes a "listener predicate" callback, which will be called when an action is dispatched, and should return `true` if the listener itself should be called:
123+
124+
```ts
125+
type ListenerPredicate<Action extends AnyAction, State> = (
126+
action: Action,
127+
currentState?: State,
128+
originalState?: State
129+
) => boolean
130+
```
131+
132+
The ["matcher" utility functions included in RTK](https://redux-toolkit.js.org/api/matching-utilities) are acceptable as predicates.
133+
134+
You may also pass an RTK action creator directly, or even a specific action type string. These are all acceptable:
135+
136+
```js
137+
// 1) Action type string
138+
middleware.addListener('todos/todoAdded', listener)
139+
// 2) RTK action creator
140+
middleware.addListener(todoAdded, listener)
141+
// 3) RTK matcher function
142+
middleware.addListener(isAnyOf(todoAdded, todoToggled), listener)
143+
// 4) Listener predicate
144+
middleware.addListener((action, currentState, previousState) => {
145+
// return comparison here
146+
}, listener)
147+
```
148+
149+
The listener may be configured to run _before_ an action reaches the reducer, _after_ the reducer, or both, by passing a `{when}` option when adding the listener:
150+
151+
```ts
152+
middleware.addListener(increment, listener, { when: 'afterReducer' })
153+
```
154+
155+
### `listenerMiddleware.removeListener(actionType, listener)`
156+
157+
Removes a given listener based on an action type string and a listener function reference.
108158
109159
### `addListenerAction`
110160
111-
A standard RTK action creator that tells the middleware to add a new listener at runtime. It accepts the same arguments as `listenerMiddleware.addListener()`.
161+
A standard RTK action creator that tells the middleware to dynamcially add a new listener at runtime.
162+
163+
> **NOTE**: It is intended to eventually accept the same arguments as `listenerMiddleware.addListener()`, but currently only accepts action types and action creators - this will hopefully be fixed in a later update.
112164
113165
Dispatching this action returns an `unsubscribe()` callback from `dispatch`.
114166
167+
```js
168+
const unsubscribe = store.dispatch(addListenerAction(predicate, listener))
169+
```
170+
115171
### `removeListenerAction`
116172

117173
A standard RTK action creator that tells the middleware to remove a listener at runtime. It requires two arguments:
@@ -120,3 +176,121 @@ A standard RTK action creator that tells the middleware to remove a listener at
120176
- `listener: ListenerCallback`: the same listener callback reference that was added originally
121177

122178
Note that matcher-based listeners currently cannot be removed with this approach - you must use the `unsubscribe()` callback that was returned when adding the listener.
179+
180+
## Usage Guide
181+
182+
### Overall Purpose
183+
184+
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.
185+
186+
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` and `getState`), 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!).
187+
188+
### Standard Usage Patterns
189+
190+
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:
191+
192+
```js
193+
middleware.addListener(
194+
isAnyOf(action1, action2, action3),
195+
(action, listenerApi) => {
196+
const user = selectUserDetails(listenerApi.getState())
197+
198+
const { specialData } = action.meta
199+
200+
analyticsApi.trackUsage(action.type, user, specialData)
201+
}
202+
)
203+
```
204+
205+
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:
206+
207+
```js
208+
middleware.addListener(resourceRequested, async (action, listenerApi) => {
209+
const { name, args } = action.payload
210+
dispatch(resourceLoading())
211+
212+
const res = await serverApi.fetch(`/api/${name}`, ...args)
213+
dispatch(resourceLoaded(res.data))
214+
})
215+
```
216+
217+
The provided `listenerPredicate` should be `(action, currentState?, originalState?) => boolean`
218+
219+
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 calling `unsubscribe()` in the body - it would run the first time the relevant action is seen, and then immediately stop and not handle any future actions.
220+
221+
### Writing Async Workflows
222+
223+
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()` and `fork()` for sagas, RxJS operators for observables), and both add a significant amount to application bundle size.
224+
225+
While this middleware is _not_ at all meant to fully replace those, it has some ability to implement long-running async workflows as well, using the `condition` method in `listenerApi`. This method is directly inspired by [the `condition` function in Temporal.io's workflow API](https://docs.temporal.io/docs/typescript/workflows/#condition) (credit to [@swyx](https://twitter.com/swyx) for the suggestion!).
226+
227+
The signature is:
228+
229+
```ts
230+
type ConditionFunction<Action extends AnyAction, State> = (
231+
predicate: ListenerPredicate<Action, State> | (() => boolean),
232+
timeout?: number
233+
) => Promise<boolean>
234+
```
235+
236+
You can use `await condition(somePredicate)` as a way to pause execution of your listener callback until some criteria is met.
237+
238+
The `predicate` will be called before and after every action is processed, and should return `true` when the condition should resolve. (It is effectively a one-shot listener itself.) If a `timeout` number (in ms) is provided, the promise will resolve `true` if the `predicate` returns first, or `false` if the timeout expires. This allows you to write comparisons like `if (await condition(predicate))`.
239+
240+
This should enable writing longer-running workflows with more complex async logic, such as [the "cancellable counter" example from Redux-Saga](https://github.com/redux-saga/redux-saga/blob/1ecb1bed867eeafc69757df8acf1024b438a79e0/examples/cancellable-counter/src/sagas/index.js).
241+
242+
An example of usage, from the test suite:
243+
244+
```ts
245+
test('condition method resolves promise when there is a timeout', async () => {
246+
let finalCount = 0
247+
let listenerStarted = false
248+
249+
middleware.addListener(
250+
// @ts-expect-error state declaration not yet working right
251+
(action, currentState: CounterState) => {
252+
return increment.match(action) && currentState.value === 0
253+
},
254+
async (action, listenerApi) => {
255+
listenerStarted = true
256+
// Wait for either the counter to hit 3, or 50ms to elapse
257+
const result = await listenerApi.condition(
258+
// @ts-expect-error state declaration not yet working right
259+
(action, currentState: CounterState) => {
260+
return currentState.value === 3
261+
},
262+
50
263+
)
264+
265+
// In this test, we expect the timeout to happen first
266+
expect(result).toBe(false)
267+
// Save the state for comparison outside the listener
268+
const latestState = listenerApi.getState() as CounterState
269+
finalCount = latestState.value
270+
},
271+
{ when: 'beforeReducer' }
272+
)
273+
274+
store.dispatch(increment())
275+
// The listener should have started right away
276+
expect(listenerStarted).toBe(true)
277+
278+
store.dispatch(increment())
279+
280+
// If we wait 150ms, the condition timeout will expire first
281+
await delay(150)
282+
// Update the state one more time to confirm the listener isn't checking it
283+
store.dispatch(increment())
284+
285+
// Handled the state update before the delay, but not after
286+
expect(finalCount).toBe(2)
287+
})
288+
```
289+
290+
### TypeScript Usage
291+
292+
The code is typed, but the behavior is incomplete. In particular, the various `state`, `dispatch`, and `extra` types in the listeners are not at all connected to the actual store types. You will likely need to manually declare or cast them as appropriate for your store setup, and possibly even use `// @ts-ignore` if the compiler doesn't accept declaring those types.
293+
294+
## Feedback
295+
296+
Please provide feedback in [RTK discussion #1648: "New experimental "action listener middleware" package"](https://github.com/reduxjs/redux-toolkit/discussions/1648).

0 commit comments

Comments
 (0)