Skip to content

Commit 7ca2790

Browse files
authored
Merge branch 'reduxjs:master' into master
2 parents 4ddfcc9 + 1751eb9 commit 7ca2790

File tree

6 files changed

+297
-15
lines changed

6 files changed

+297
-15
lines changed

docs/api/createEntityAdapter.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ If provided, the `state.ids` array will be kept in sorted order based on compari
103103

104104
If not provided, the `state.ids` array will not be sorted, and no guarantees are made about the ordering. In other words, `state.ids` can be expected to behave like a standard Javascript array.
105105

106+
Note that sorting only kicks in when state is changed via one of the CRUD functions below (for example, `addOne()`, `updateMany()`).
107+
106108
## Return Value
107109

108110
A "entity adapter" instance. An entity adapter is a plain JS object (not a class) containing the generated reducer functions, the original provided `selectId` and `sortComparer` callbacks, a method to generate an initial "entity state" value, and functions to generate a set of globalized and non-globalized memoized selector functions for this entity type.

docs/api/createListenerMiddleware.mdx

Lines changed: 147 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ interface ListenerMiddlewareInstance<
115115
> {
116116
middleware: ListenerMiddleware<State, Dispatch, ExtraArgument>
117117
startListening: (options: AddListenerOptions) => Unsubscribe
118-
stopListening: (options: AddListenerOptions) => boolean
118+
stopListening: (
119+
options: AddListenerOptions & UnsubscribeListenerOptions
120+
) => boolean
119121
clearListeners: () => void
120122
}
121123
```
@@ -172,7 +174,7 @@ type ListenerPredicate<Action extends AnyAction, State> = (
172174
) => boolean
173175

174176
type UnsubscribeListener = (
175-
unsuscribeOptions?: UnsubscribeListenerOptions
177+
unsubscribeOptions?: UnsubscribeListenerOptions
176178
) => void
177179

178180
interface UnsubscribeListenerOptions {
@@ -300,7 +302,76 @@ store.dispatch(clearAllListeners())
300302

301303
## Listener API
302304

303-
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.
305+
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.
306+
307+
```ts no-transpile
308+
export interface ListenerEffectAPI<
309+
State,
310+
Dispatch extends ReduxDispatch<AnyAction>,
311+
ExtraArgument = unknown
312+
> extends MiddlewareAPI<Dispatch, State> {
313+
// NOTE: MiddlewareAPI contains `dispatch` and `getState` already
314+
315+
/**
316+
* Returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran.
317+
* This function can **only** be invoked **synchronously**, it throws error otherwise.
318+
*/
319+
getOriginalState: () => State
320+
/**
321+
* Removes the listener entry from the middleware and prevent future instances of the listener from running.
322+
* It does **not** cancel any active instances.
323+
*/
324+
unsubscribe(): void
325+
/**
326+
* It will subscribe a listener if it was previously removed, noop otherwise.
327+
*/
328+
subscribe(): void
329+
/**
330+
* Returns a promise that resolves when the input predicate returns `true` or
331+
* rejects if the listener has been cancelled or is completed.
332+
*
333+
* The return value is `true` if the predicate succeeds or `false` if a timeout is provided and expires first.
334+
*/
335+
condition: ConditionFunction<State>
336+
/**
337+
* Returns a promise that resolves when the input predicate returns `true` or
338+
* rejects if the listener has been cancelled or is completed.
339+
*
340+
* The return value is the `[action, currentState, previousState]` combination that the predicate saw as arguments.
341+
*
342+
* The promise resolves to null if a timeout is provided and expires first.
343+
*/
344+
take: TakePattern<State>
345+
/**
346+
* Cancels all other running instances of this same listener except for the one that made this call.
347+
*/
348+
cancelActiveListeners: () => void
349+
/**
350+
* An abort signal whose `aborted` property is set to `true`
351+
* if the listener execution is either aborted or completed.
352+
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
353+
*/
354+
signal: AbortSignal
355+
/**
356+
* Returns a promise that resolves after `timeoutMs` or
357+
* rejects if the listener has been cancelled or is completed.
358+
*/
359+
delay(timeoutMs: number): Promise<void>
360+
/**
361+
* Queues in the next microtask the execution of a task.
362+
*/
363+
fork<T>(executor: ForkedTaskExecutor<T>): ForkedTask<T>
364+
/**
365+
* Returns a promise that resolves when `waitFor` resolves or
366+
* rejects if the listener has been cancelled or is completed.
367+
* @param promise
368+
*/
369+
pause<M>(promise: Promise<M>): Promise<M>
370+
extra: ExtraArgument
371+
}
372+
```
373+
374+
These can be divided into several categories.
304375

305376
### Store Interaction Methods
306377

@@ -391,13 +462,19 @@ To fix this, the middleware provides types for defining "pre-typed" versions of
391462
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'
392463
import type { TypedStartListening, TypedAddListener } from '@reduxjs/toolkit'
393464

394-
import type { RootState } from './store'
465+
import type { RootState, AppDispatch } from './store'
395466

396467
export const listenerMiddleware = createListenerMiddleware()
397468

469+
export type AppStartListening = TypedStartListening<RootState, AppDispatch>
470+
398471
export const startAppListening =
399-
listenerMiddleware.startListening as TypedStartListening<RootState>
400-
export const addAppListener = addListener as TypedAddListener<RootState>
472+
listenerMiddleware.startListening as AppStartListening
473+
474+
export const addAppListener = addListener as TypedAddListener<
475+
RootState,
476+
AppDispatch
477+
>
401478
```
402479

403480
Then import and use those pre-typed methods in your components.
@@ -410,7 +487,7 @@ This middleware lets you run additional logic when some action is dispatched, as
410487

411488
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!).
412489

413-
The middleware includes several async workflow primitives that are sufficient to write equivalents to many Redux-Saga effects operators like `takeLatest`, `takeLeading`, and `debounce`.
490+
The middleware includes several async workflow primitives that are sufficient to write equivalents to many Redux-Saga effects operators like `takeLatest`, `takeLeading`, and `debounce`, although none of those methods are directly included. (See [the listener middleware tests file for examples of how to write code equivalent to those effects](https://github.com/reduxjs/redux-toolkit/blob/03eafd5236f16574935cdf1c5958e32ee8cf3fbe/packages/toolkit/src/listenerMiddleware/tests/effectScenarios.test.ts#L74-L363).)
414491

415492
### Standard Usage Patterns
416493

@@ -573,7 +650,7 @@ listenerMiddleware.startListening({
573650
574651
### Complex Async Workflows
575652
576-
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 as `throttle`, `debounce`, `takeLatest`, `takeLeading`, and `fork/join`. Some examples from the test suite:
653+
The provided async workflow primitives (`cancelActiveListeners`, `unsubscribe`, `subscribe`, `take`, `condition`, `pause`, `delay`) can be used to implement behavior that is equivalent to many of the more complex async workflow capabilities found in the Redux-Saga library. This includes effects such as `throttle`, `debounce`, `takeLatest`, `takeLeading`, and `fork/join`. Some examples from the test suite:
577654
578655
```js
579656
test('debounce / takeLatest', async () => {
@@ -729,3 +806,65 @@ While this pattern is _possible_, **we do not necessarily _recommend_ doing this
729806
At the same time, this _is_ a valid technique, both in terms of API behavior and potential use cases. It's been common to lazy-load sagas as part of a code-split app, and that has often required some complex additional setup work to "inject" sagas. In contrast, `dispatch(addListener())` fits naturally into a React component's lifecycle.
730807
731808
So, while we're not specifically encouraging use of this pattern, it's worth documenting here so that users are aware of it as a possibility.
809+
810+
### Organizing Listeners in Files
811+
812+
As a starting point, **it's best to create the listener middleware in a separate file, such as `app/listenerMiddleware.ts`, rather than in the same file as the store**. This avoids any potential circular import problems from other files trying to import `middleware.addListener`.
813+
814+
From there, so far we've come up with three different ways to organize listener functions and setup.
815+
816+
First, you can import effect callbacks from slice files into the middleware file, and add the listeners:
817+
818+
```ts no-transpile title="app/listenerMiddleware.ts"
819+
import { action1, listener1 } from '../features/feature1/feature1Slice'
820+
import { action2, listener2 } from '../features/feature2/feature1Slice'
821+
822+
listenerMiddleware.startListening({ actionCreator: action1, effect: listener1 })
823+
listenerMiddleware.startListening({ actionCreator: action2, effect: listener2 })
824+
```
825+
826+
This is probably the simplest option, and mirrors how the store setup pulls together all the slice reducers to create the app.
827+
828+
The second option is the opposite: have the slice files import the middleware and directly add their listeners:
829+
830+
```ts no-transpile title="features/feature1/feature1Slice.ts"
831+
import { listenerMiddleware } from '../../app/listenerMiddleware'
832+
833+
const feature1Slice = createSlice(/* */)
834+
const { action1 } = feature1Slice.actions
835+
836+
export default feature1Slice.reducer
837+
838+
listenerMiddleware.startListening({
839+
actionCreator: action1,
840+
effect: () => {},
841+
})
842+
```
843+
844+
This keeps all the logic in the slice, although it does lock the setup into a single middleware instance.
845+
846+
The third option is to create a setup function in the slice, but let the listener file call that on startup:
847+
848+
```ts no-transpile title="features/feature1/feature1Slice.ts"
849+
import type { AppStartListening } from '../../app/listenerMiddleware'
850+
851+
const feature1Slice = createSlice(/* */)
852+
const { action1 } = feature1Slice.actions
853+
854+
export default feature1Slice.reducer
855+
856+
export const addFeature1Listeners = (startListening: AppStartListening) => {
857+
startListening({
858+
actionCreator: action1,
859+
effect: () => {},
860+
})
861+
}
862+
```
863+
864+
```ts no-transpile title="app/listenerMiddleware.ts"
865+
import { addFeature1Listeners } from '../features/feature1/feature1Slice'
866+
867+
addFeature1Listeners(listenerMiddleware.startListening)
868+
```
869+
870+
Feel free to use whichever of these approaches works best in your app.

examples/action-listener/counter/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"scripts": {
1818
"start": "react-scripts start",
1919
"build": "react-scripts build",
20-
"lint:ts": "tsc --noEmit"
20+
"lint:ts": "tsc --noEmit",
21+
"test": "react-scripts test --runInBand"
2122
},
2223
"eslintConfig": {
2324
"extends": [
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'
2+
import { setupCounterListeners } from '../listeners'
3+
import { counterSlice, counterActions, counterSelectors } from '../slice'
4+
import type { AppStartListening } from '../../../store'
5+
6+
function delay(timerMs: number): Promise<number> {
7+
return new Promise((resolve) => {
8+
setTimeout(resolve, timerMs, timerMs)
9+
})
10+
}
11+
12+
jest.useRealTimers()
13+
14+
describe('counter - listeners', () => {
15+
const onMiddlewareError = jest.fn((): void => {}) // https://jestjs.io/docs/mock-function-api
16+
17+
/**
18+
* @see https://redux-toolkit.js.org/api/createListenerMiddleware
19+
*/
20+
const listenerMiddlewareInstance = createListenerMiddleware({
21+
onError: onMiddlewareError,
22+
})
23+
24+
function setupTestStore() {
25+
return configureStore({
26+
reducer: {
27+
[counterSlice.name]: counterSlice.reducer,
28+
},
29+
middleware: (gDM) => gDM().prepend(listenerMiddlewareInstance.middleware),
30+
})
31+
}
32+
33+
let store = setupTestStore()
34+
35+
beforeEach(() => {
36+
listenerMiddlewareInstance.clearListeners() // Stops and cancels active listeners https://redux-toolkit.js.org/api/createListenerMiddleware#clearlisteners
37+
onMiddlewareError.mockClear() // https://jestjs.io/docs/mock-function-api#mockfnmockclear
38+
store = setupTestStore() // resets store state
39+
40+
setupCounterListeners(
41+
listenerMiddlewareInstance.startListening as AppStartListening
42+
)
43+
})
44+
45+
describe('onUpdateAsync', () => {
46+
const delayMs = 10
47+
const initialValue = 2
48+
const delta = 2
49+
50+
it('asynchronously adds `payload.delta` after `payload.delayMs` to counter', async () => {
51+
store.dispatch(counterActions.addCounter({ initialValue }))
52+
53+
const { id } = counterSelectors.selectAll(store.getState())[0]
54+
55+
store.dispatch(counterActions.updateByAsync({ id, delayMs, delta }))
56+
57+
expect(counterSelectors.selectById(store.getState(), id)?.value).toBe(
58+
initialValue
59+
)
60+
61+
await delay(delayMs)
62+
63+
expect(counterSelectors.selectById(store.getState(), id)?.value).toBe(
64+
initialValue + delta
65+
)
66+
})
67+
68+
it('stops updates if cancelAsyncUpdates is dispatched', async () => {
69+
store.dispatch(counterActions.addCounter({ initialValue }))
70+
71+
const { id } = counterSelectors.selectAll(store.getState())[0]
72+
73+
store.dispatch(counterActions.updateByAsync({ id, delayMs, delta }))
74+
75+
expect(counterSelectors.selectById(store.getState(), id)?.value).toBe(
76+
initialValue
77+
)
78+
79+
store.dispatch(counterActions.cancelAsyncUpdates(id))
80+
81+
await delay(delayMs)
82+
83+
expect(counterSelectors.selectById(store.getState(), id)?.value).toBe(
84+
initialValue
85+
)
86+
})
87+
})
88+
89+
describe('onUpdateByPeriodically', () => {
90+
const intervalMs = 10
91+
const initialValue = 2
92+
const delta = 2
93+
94+
it('periodically adds `payload.delta` after `payload.intervalMs` to counter', async () => {
95+
store.dispatch(counterActions.addCounter({ initialValue }))
96+
97+
const { id } = counterSelectors.selectAll(store.getState())[0]
98+
99+
store.dispatch(
100+
counterActions.updateByPeriodically({ id, intervalMs, delta })
101+
)
102+
103+
for (let i = 0; i < 2; i++) {
104+
expect(counterSelectors.selectById(store.getState(), id)?.value).toBe(
105+
initialValue + i * delta
106+
)
107+
108+
await delay(intervalMs)
109+
110+
expect(counterSelectors.selectById(store.getState(), id)?.value).toBe(
111+
initialValue + (i + 1) * delta
112+
)
113+
}
114+
})
115+
116+
it('stops updates if cancelAsyncUpdates is dispatched', async () => {
117+
store.dispatch(counterActions.addCounter({ initialValue }))
118+
119+
const { id } = counterSelectors.selectAll(store.getState())[0]
120+
121+
store.dispatch(
122+
counterActions.updateByPeriodically({ id, intervalMs, delta })
123+
)
124+
125+
await delay(intervalMs)
126+
expect(counterSelectors.selectById(store.getState(), id)?.value).toBe(
127+
initialValue + delta
128+
)
129+
130+
store.dispatch(counterActions.cancelAsyncUpdates(id))
131+
132+
await delay(intervalMs)
133+
134+
expect(counterSelectors.selectById(store.getState(), id)?.value).toBe(
135+
initialValue + delta
136+
)
137+
})
138+
})
139+
})

examples/action-listener/counter/src/store.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@ export { store }
2626

2727
// Infer the `RootState` and `AppDispatch` types from the store itself
2828
export type RootState = ReturnType<typeof store.getState>
29-
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
29+
// @see https://redux-toolkit.js.org/usage/usage-with-typescript#getting-the-dispatch-type
3030
export type AppDispatch = typeof store.dispatch
3131

3232
export type AppListenerEffectAPI = ListenerEffectAPI<RootState, AppDispatch>
3333

34-
export type AppStartListening = TypedStartListening<RootState>
35-
export type AppAddListener = TypedAddListener<RootState>
34+
// @see https://redux-toolkit.js.org/api/createListenerMiddleware#typescript-usage
35+
export type AppStartListening = TypedStartListening<RootState, AppDispatch>
36+
export type AppAddListener = TypedAddListener<RootState, AppDispatch>
3637

3738
export const startAppListening =
3839
listenerMiddlewareInstance.startListening as AppStartListening

0 commit comments

Comments
 (0)