|
| 1 | +# RTK Incubator - Action Listener Middleware |
| 2 | + |
| 3 | +This package provides an experimental callback-based Redux middleware that we hope to include in Redux Toolkit directly in a future 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. |
| 4 | + |
| 5 | +This middleware lets you define 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, and similar to thunks in level of complexity and concept. |
| 6 | + |
| 7 | +## Installation |
| 8 | + |
| 9 | +```bash |
| 10 | +npm i @rtk-incubator/action-listener-middleware |
| 11 | + |
| 12 | +yarn add @rtk-incubator/action-listener-middleware |
| 13 | +``` |
| 14 | + |
| 15 | +### Basic Usage |
| 16 | + |
| 17 | +```js |
| 18 | +import { configureStore } from '@reduxjs/toolkit' |
| 19 | +import { createActionListenerMiddleware } from '@rtk-incubator/action-listener-middleware' |
| 20 | + |
| 21 | +import todosReducer, { |
| 22 | + todoAdded, |
| 23 | + todoToggled, |
| 24 | + todoDeleted, |
| 25 | +} from '../features/todos/todosSlice' |
| 26 | + |
| 27 | +// Create the middleware instance |
| 28 | +const listenerMiddleware = createActionListenerMiddleware() |
| 29 | + |
| 30 | +// Add one or more listener callbacks for specific actions |
| 31 | +listenerMiddleware.addListener(todoAdded, (action, listenerApi) => { |
| 32 | + // Run whatever additional side-effect-y logic you want here |
| 33 | + const { text } = action.payload |
| 34 | + console.log('Todo added: ', text) |
| 35 | + |
| 36 | + if (text === 'Buy milk') { |
| 37 | + // Use the listener API methods to dispatch, get state, or unsubscribe the listener |
| 38 | + listenerApi.unsubscribe() |
| 39 | + } |
| 40 | +}) |
| 41 | + |
| 42 | +const store = configureStore({ |
| 43 | + reducer: { |
| 44 | + todos: todosReducer, |
| 45 | + }, |
| 46 | + // Add the middleware to the store |
| 47 | + middleware: (getDefaultMiddleware) => |
| 48 | + getDefaultMiddleware().concat(listenerMiddleware), |
| 49 | +}) |
| 50 | +``` |
| 51 | + |
| 52 | +## Motivation |
| 53 | + |
| 54 | +The Redux community has settled around three primary side effects libraries over time: |
| 55 | + |
| 56 | +- Thunks use basic functions passed to `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 actions |
| 57 | +- Sagas use generator functions and a custom set of "effects" APIs, which are then executed by a middleware. Sagas let users write powerful async logic and workflows that can respond to any dispatched action, including "background thread"-type behavior like infinite loops and cancelation. |
| 58 | +- Observables use RxJS obesrvable operators. Observables form pipelines that do arbitrary processing similar to sagas, but with a more functional API style. |
| 59 | + |
| 60 | +All three of those have strengths and weaknesses: |
| 61 | + |
| 62 | +- Thunks are simple to use, but can only run imperative code and have no way to _respond_ to dispatched actions |
| 63 | +- Sagas are extremely powerful, but require learning generator functions and the specifics of `redux-saga`'s effects API, and are overkill for many simpler use cases |
| 64 | +- Observables are also powerful, but RxJS is its own complex API to learn and they can be hard to debug |
| 65 | + |
| 66 | +If you need to run some code in response to a specific action being dispatched, you _could_ write a custom middleware: |
| 67 | + |
| 68 | +```js |
| 69 | +const myMiddleware = (storeAPI) => (next) => (action) => { |
| 70 | + if (action.type === 'some/specificAction') { |
| 71 | + console.log('Do something useful here') |
| 72 | + } |
| 73 | + |
| 74 | + return next(action) |
| 75 | +} |
| 76 | +``` |
| 77 | + |
| 78 | +However, it would be nice to have a more structured API to help abstract this process. |
| 79 | + |
| 80 | +The `createActionListenerMiddleware` API provides that structure. |
| 81 | + |
| 82 | +For more background and debate over the use cases and API design, see the original discussion issue and PR: |
| 83 | + |
| 84 | +- [RTK issue #237: Add an action listener middleware](https://github.com/reduxjs/redux-toolkit/issues/237) |
| 85 | +- [RTK PR #547: yet another attempt at an action listener middleware](https://github.com/reduxjs/redux-toolkit/pull/547) |
| 86 | + |
| 87 | +## Usage and API |
| 88 | + |
| 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. |
| 90 | + |
| 91 | +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. |
| 92 | + |
| 93 | +### `createActionListenerMiddleware` |
| 94 | + |
| 95 | +Creates an instance of the middleware, which should then be added to the store via the `middleware` parameter. |
| 96 | + |
| 97 | +### `listenerMiddleware.addListener(actionType, listener, options?) : Unsubscribe` |
| 98 | + |
| 99 | +Statically adds a new listener callback to the middleware. |
| 100 | + |
| 101 | +Parameters: |
| 102 | + |
| 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. |
| 105 | +- `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'. |
| 106 | + |
| 107 | +The return value is a standard `unsubscribe()` callback that will remove this listener. |
| 108 | + |
| 109 | +### `addListenerAction` |
| 110 | + |
| 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()`. |
| 112 | + |
| 113 | +Dispatching this action returns an `unsubscribe()` callback from `dispatch`. |
| 114 | + |
| 115 | +### `removeListenerAction` |
| 116 | + |
| 117 | +A standard RTK action creator that tells the middleware to remove a listener at runtime. It requires two arguments: |
| 118 | + |
| 119 | +- `typeOrActionCreator: string | ActionCreator`: the same action type / action creator that was used to add the listener |
| 120 | +- `listener: ListenerCallback`: the same listener callback reference that was added originally |
| 121 | + |
| 122 | +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. |
0 commit comments