Skip to content

Commit ec7b17d

Browse files
committed
Merge branch 'master' into v1.7.0-integration
2 parents 2e9eb2a + 8feda19 commit ec7b17d

21 files changed

+1823
-352
lines changed

.github/workflows/listenerTests.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: CI
2+
on: [push, pull_request]
3+
4+
jobs:
5+
build:
6+
name: Test Listener Middleware on Node ${{ matrix.node }}
7+
8+
runs-on: ubuntu-latest
9+
strategy:
10+
matrix:
11+
node: ['14.x']
12+
13+
steps:
14+
- name: Checkout repo
15+
uses: actions/checkout@v2
16+
17+
- name: Use node ${{ matrix.node }}
18+
uses: actions/setup-node@v2
19+
with:
20+
node-version: ${{ matrix.node }}
21+
cache: 'yarn'
22+
23+
- name: Install deps
24+
run: yarn install
25+
26+
# The middleware apparently needs RTK built first for tests to compile (?!?)
27+
- name: Build RTK
28+
run: cd packages/toolkit && yarn build
29+
30+
- name: Run action listener tests
31+
run: cd packages/action-listener-middleware && yarn test

docs/api/createReducer.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,11 +286,11 @@ const reducer = createReducer(0, (builder) => {
286286
builder
287287
.addCase('increment', (state) => state + 1)
288288
.addMatcher(
289-
(action) => action.startsWith('i'),
289+
(action) => action.type.startsWith('i'),
290290
(state) => state * 5
291291
)
292292
.addMatcher(
293-
(action) => action.endsWith('t'),
293+
(action) => action.type.endsWith('t'),
294294
(state) => state + 2
295295
)
296296
})

docs/api/getDefaultMiddleware.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const store = configureStore({
6262
// Store has all of the default middleware added, _plus_ the logger middleware
6363
```
6464

65-
It is preferrable to use the chainable `.concat(...)` and `.prepend(...)` methods of the returned `MiddlewareArray` instead of the array spread operator, as the latter can lose valuable type information under some circumstances.
65+
It is preferable to use the chainable `.concat(...)` and `.prepend(...)` methods of the returned `MiddlewareArray` instead of the array spread operator, as the latter can lose valuable type information under some circumstances.
6666

6767
## Included Default Middleware
6868

docs/rtk-query/api/fetchBaseQuery.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const pokemonApi = createApi({
5757
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), // Set the baseUrl for every endpoint below
5858
endpoints: (builder) => ({
5959
getPokemonByName: builder.query({
60-
query: (name: string) => `pokemon/${name}`, // Will make a request like https://pokeapi.co/api/v2/bulbasaur
60+
query: (name: string) => `pokemon/${name}`, // Will make a request like https://pokeapi.co/api/v2/pokemon/bulbasaur
6161
}),
6262
updatePokemon: builder.mutation({
6363
query: ({ name, patch }) => ({

docs/rtk-query/usage/automated-refetching.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ The `providesTags` argument can either be an array of `string` (such as `['Post'
4040

4141
### Invalidating tags
4242

43-
_see also: [invalidatesTags API reference](../api/createApi.mdx#invalidatesTags)_
43+
_see also: [invalidatesTags API reference](../api/createApi.mdx#invalidatestags)_
4444

4545
A _mutation_ can _invalidate_ specific cached data based on the tags. Doing so determines which cached data will be either refetched or removed from the cache.
4646

docs/rtk-query/usage/prefetching.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ export function usePrefetchImmediately<T extends EndpointNames>(
8989
arg: Parameters<typeof api.endpoints[T]['initiate']>[0],
9090
options: PrefetchOptions = {}
9191
) {
92-
const dispatch = useDispatch()
92+
const dispatch = useAppDispatch()
9393
useEffect(() => {
94-
dispatch(api.util.prefetch(endpoint, arg, options))
94+
dispatch(api.util.prefetch(endpoint, arg as any, options))
9595
}, [])
9696
}
9797

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"build:examples": "yarn workspaces foreach --include '@reduxjs/*' --include '@examples-query-react/*' -vtp run build",
4747
"build:docs": "yarn workspace website run build",
4848
"build:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' --include '@rtk-incubator/*' --topological-dev run build",
49-
"test:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' run test",
49+
"test:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' --include '@rtk-incubator/*' run test",
5050
"dev:docs": "yarn workspace website run start"
5151
}
5252
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
dist
22
node_modules
3-
yarn-error.log
3+
yarn-error.log
4+
coverage

packages/action-listener-middleware/README.md

Lines changed: 188 additions & 8 deletions
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,94 @@ 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

89-
`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.
91+
`createActionListenerMiddleware` lets you add listeners by providing a "listener callback" containing additional logic, a way to specify when that callback should run based on dispatched actions or state changes, and whether your callback should run before or after the action is processed by the reducers.
92+
93+
The middleware then gives you access to `dispatch` and `getState` for use in your listener callback's logic. Callbacks can also unsubscribe to stop from being run again in the future.
9094

9195
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.
9296

93-
### `createActionListenerMiddleware`
97+
### `createActionListenerMiddleware: (options?: CreateMiddlewareOptions) => Middleware`
9498

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

97-
### `listenerMiddleware.addListener(actionType, listener, options?) : Unsubscribe`
101+
Current options are:
102+
103+
- `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).
104+
105+
- `onError`: an optional error handler that gets called with synchronous errors raised by `listener` and `predicate`.
106+
107+
### `listenerMiddleware.addListener(predicate, listener, options?) : Unsubscribe`
98108

99109
Statically adds a new listener callback to the middleware.
100110

101111
Parameters:
102112

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.
113+
- `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`.
114+
- `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:
115+
- `dispatch: Dispatch`: the standard `store.dispatch` method
116+
- `getState: () => State`: the standard `store.getState` method
117+
- `getOriginalState: () => State`: returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran
118+
- `currentPhase: 'beforeReducer' | 'afterReducer'`: an string indicating when the listener is being called relative to the action processing
119+
- `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.
120+
- `extra`: the "extra argument" that was provided as part of the middleware setup, if any
121+
- `unsubscribe` will remove the listener from the middleware
105122
- `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'.
106123

107124
The return value is a standard `unsubscribe()` callback that will remove this listener.
108125

126+
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 existing `unsubscribe` method will be returned.
127+
128+
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:
129+
130+
```ts
131+
type ListenerPredicate<Action extends AnyAction, State> = (
132+
action: Action,
133+
currentState?: State,
134+
originalState?: State
135+
) => boolean
136+
```
137+
138+
The ["matcher" utility functions included in RTK](https://redux-toolkit.js.org/api/matching-utilities) are acceptable as predicates.
139+
140+
You may also pass an RTK action creator directly, or even a specific action type string. These are all acceptable:
141+
142+
```js
143+
// 1) Action type string
144+
middleware.addListener('todos/todoAdded', listener)
145+
// 2) RTK action creator
146+
middleware.addListener(todoAdded, listener)
147+
// 3) RTK matcher function
148+
middleware.addListener(isAnyOf(todoAdded, todoToggled), listener)
149+
// 4) Listener predicate
150+
middleware.addListener((action, currentState, previousState) => {
151+
// return comparison here
152+
}, listener)
153+
```
154+
155+
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:
156+
157+
```ts
158+
middleware.addListener(increment, listener, { when: 'afterReducer' })
159+
```
160+
161+
### `listenerMiddleware.removeListener(actionType, listener)`
162+
163+
Removes a given listener based on an action type string and a listener function reference.
164+
109165
### `addListenerAction`
110166
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()`.
167+
A standard RTK action creator that tells the middleware to dynamcially add a new listener at runtime.
168+
169+
> **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.
112170
113171
Dispatching this action returns an `unsubscribe()` callback from `dispatch`.
114172
173+
```js
174+
const unsubscribe = store.dispatch(addListenerAction(predicate, listener))
175+
```
176+
115177
### `removeListenerAction`
116178

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

122184
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.
185+
186+
## Usage Guide
187+
188+
### Overall Purpose
189+
190+
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.
191+
192+
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!).
193+
194+
### Standard Usage Patterns
195+
196+
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:
197+
198+
```js
199+
middleware.addListener(
200+
isAnyOf(action1, action2, action3),
201+
(action, listenerApi) => {
202+
const user = selectUserDetails(listenerApi.getState())
203+
204+
const { specialData } = action.meta
205+
206+
analyticsApi.trackUsage(action.type, user, specialData)
207+
}
208+
)
209+
```
210+
211+
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:
212+
213+
```js
214+
middleware.addListener(resourceRequested, async (action, listenerApi) => {
215+
const { name, args } = action.payload
216+
dispatch(resourceLoading())
217+
218+
const res = await serverApi.fetch(`/api/${name}`, ...args)
219+
dispatch(resourceLoaded(res.data))
220+
})
221+
```
222+
223+
The provided `listenerPredicate` should be `(action, currentState?, originalState?) => boolean`
224+
225+
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.
226+
227+
### Writing Async Workflows
228+
229+
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.
230+
231+
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!).
232+
233+
The signature is:
234+
235+
```ts
236+
type ConditionFunction<Action extends AnyAction, State> = (
237+
predicate: ListenerPredicate<Action, State> | (() => boolean),
238+
timeout?: number
239+
) => Promise<boolean>
240+
```
241+
242+
You can use `await condition(somePredicate)` as a way to pause execution of your listener callback until some criteria is met.
243+
244+
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))`.
245+
246+
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).
247+
248+
An example of usage, from the test suite:
249+
250+
```ts
251+
test('condition method resolves promise when there is a timeout', async () => {
252+
let finalCount = 0
253+
let listenerStarted = false
254+
255+
middleware.addListener(
256+
// @ts-expect-error state declaration not yet working right
257+
(action, currentState: CounterState) => {
258+
return increment.match(action) && currentState.value === 0
259+
},
260+
async (action, listenerApi) => {
261+
listenerStarted = true
262+
// Wait for either the counter to hit 3, or 50ms to elapse
263+
const result = await listenerApi.condition(
264+
// @ts-expect-error state declaration not yet working right
265+
(action, currentState: CounterState) => {
266+
return currentState.value === 3
267+
},
268+
50
269+
)
270+
271+
// In this test, we expect the timeout to happen first
272+
expect(result).toBe(false)
273+
// Save the state for comparison outside the listener
274+
const latestState = listenerApi.getState() as CounterState
275+
finalCount = latestState.value
276+
},
277+
{ when: 'beforeReducer' }
278+
)
279+
280+
store.dispatch(increment())
281+
// The listener should have started right away
282+
expect(listenerStarted).toBe(true)
283+
284+
store.dispatch(increment())
285+
286+
// If we wait 150ms, the condition timeout will expire first
287+
await delay(150)
288+
// Update the state one more time to confirm the listener isn't checking it
289+
store.dispatch(increment())
290+
291+
// Handled the state update before the delay, but not after
292+
expect(finalCount).toBe(2)
293+
})
294+
```
295+
296+
### TypeScript Usage
297+
298+
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.
299+
300+
## Feedback
301+
302+
Please provide feedback in [RTK discussion #1648: "New experimental "action listener middleware" package"](https://github.com/reduxjs/redux-toolkit/discussions/1648).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ "type": "commonjs" }

0 commit comments

Comments
 (0)