Skip to content

Commit 05bdfbb

Browse files
add addMatcher to builder notations & actionMatchers argumen… (#610)
* add `addMatcher` to builder notations & `actionMatchers` argument to createReducer * execute all matching reducers instead of only the first one * add addDefaultCase functionality * docs * api report * Rework createReducer API docs to cover new parameters and behavior * Clarify createSlice builder and prepare callback usage * Bump Immer dep Co-authored-by: Mark Erikson <mark@isquaredsoftware.com>
1 parent ff2bdc1 commit 05bdfbb

13 files changed

+793
-104
lines changed

docs/api/createReducer.md

Lines changed: 202 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ hide_title: true
77

88
# `createReducer()`
99

10+
## Overview
11+
1012
A utility that simplifies creating Redux reducer functions, by defining them as lookup tables of functions to handle each action type. It also allows you to drastically simplify immutable update logic, by writing "mutative" code inside your reducers.
1113

1214
Redux [reducers](https://redux.js.org/basics/reducers) are often implemented using a `switch` statement, with one `case` for every handled action type.
@@ -24,9 +26,19 @@ function counterReducer(state = 0, action) {
2426
}
2527
```
2628

27-
This approach works well, but is a bit boilerplate-y and error-prone. For instance, it is easy to forget the `default` case or setting the initial state.
29+
This approach works well, but is a bit boilerplate-y and error-prone. For instance, it is easy to forget the `default` case or setting the initial state.The `createReducer` helper streamlines the implementation of such reducers.
30+
31+
## Parameters
32+
33+
`createReducer` accepts four possible parameters, with the first two required.
34+
35+
### `initialState`
36+
37+
The initial state that should be used when the reducer is called the first time.
2838

29-
The `createReducer` helper streamlines the implementation of such reducers. It takes two arguments. The first one is the initial state. The second is an object mapping from action types to _case reducers_, each of which handles one specific action type.
39+
### `caseReducers`
40+
41+
An object mapping from action types to _case reducers_, each of which handles one specific action type.
3042

3143
```js
3244
const counterReducer = createReducer(0, {
@@ -36,10 +48,7 @@ const counterReducer = createReducer(0, {
3648
```
3749

3850
Action creators that were generated using [`createAction`](./createAction.md) may be used directly as the keys here, using
39-
computed property syntax.
40-
41-
> **Note**: If you are using TypeScript, we recommend using the `builder callback` API that is shown below. If you do not use the `builder callback` and are using TypeScript, you will need to use `actionCreator.type` or `actionCreator.toString()`
42-
> to force the TS compiler to accept the computed property. Please see [Usage With TypeScript](./../usage/usage-with-typescript.md#type-safety-with-extraReducers) for further details.
51+
computed property syntax:
4352

4453
```js
4554
const increment = createAction('increment')
@@ -51,21 +60,43 @@ const counterReducer = createReducer(0, {
5160
})
5261
```
5362

54-
### The "builder callback" API
63+
Alternately, the second argument may be a "builder callback" function that can be used to add case handlers for specific action types, match against a range of action types, or provide a fallback default case if no other actions matched:
5564

56-
Instead of using a simple object as an argument to `createReducer`, you can also provide a callback that receives an `ActionReducerMapBuilder` instance:
65+
```js
66+
const initialState = {
67+
counter: 0,
68+
rejectedActions: 0,
69+
unhandledActions: 0
70+
}
5771

58-
```typescript
59-
createReducer(0, builder =>
60-
builder.addCase(increment, (state, action) => {
61-
// action is inferred correctly here
62-
})
63-
)
72+
const exampleReducer = createReducer(initialState, builder => {
73+
builder
74+
.addCase('counter', state => {
75+
state.counter++
76+
})
77+
.addMatcher(
78+
action => action.endsWith('/rejected'),
79+
(state, action) => {
80+
state.rejectedActions++
81+
}
82+
)
83+
.addDefaultCase((state, action) => {
84+
state.unhandledActions++
85+
})
86+
})
6487
```
6588

66-
This is intended for use with TypeScript, as passing a plain object full of reducer functions cannot infer their types correctly in this case. It has no real benefit when used with plain JS.
89+
See [the `builder callback` API](#the-builder-callback-api) below for details on defining reducers using this syntax.
6790

68-
We recommend using this API if stricter type safety is necessary when defining reducer argument objects.
91+
> **Note**: If you are using TypeScript, we specifically recommend using the builder callback API to get proper inference of TS types for action objects. If you do not use the builder callback and are using TypeScript, you will need to use `actionCreator.type` or `actionCreator.toString()` as the key to force the TS compiler to accept the computed property. Please see [Usage With TypeScript](./../usage/usage-with-typescript.md#type-safety-with-extraReducers) for further details.
92+
93+
### `actionMatchers`
94+
95+
An optional array of objects that include a `matcher` function to determine if an action should be handled, and a `reducer` function that updates the state. This argument will be ignored if the second argument is a builder callback.
96+
97+
### `defaultCase`
98+
99+
A reducer that will be run if no other case reducers or matchers handle a given action. This argument will be ignored if the second argument is a builder callback.
69100

70101
## Direct State Mutation
71102

@@ -92,7 +123,7 @@ const todosReducer = createReducer([], {
92123
})
93124
```
94125

95-
The `addTodo` reducer is pretty easy to follow if you know the [ES6 spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax). However, the code for `toggleTodo` is much less straightforward, especially considering that it only sets a single flag.
126+
The `addTodo` reducer is straightforward if you know the [ES6 spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax). However, the code for `toggleTodo` is much less straightforward, especially considering that it only sets a single flag.
96127

97128
To make things easier, `createReducer` uses [immer](https://github.com/mweststrate/immer) to let you write reducers as if they were mutating the state directly. In reality, the reducer receives a proxy state that translates all mutations into equivalent copy operations.
98129

@@ -117,7 +148,7 @@ const todosReducer = createReducer([], {
117148
})
118149
```
119150

120-
If you choose to write reducers in this style, make sure to learn about the [pitfalls mentioned in the immer docs](https://immerjs.github.io/immer/docs/pitfalls) . Most importantly, you need to ensure that you either mutate the `state` argument or return a new state, _but not both_. For example, the following reducer would throw an exception if a `toggleTodo` action is passed:
151+
Writing "mutating" reducers simplifies the code. It's shorter, there's less indirection, and it eliminates common mistakes made while spreading nested state. However, the use of Immer does add some "magic", and Immer has its own nuances in behavior. You should read through [pitfalls mentioned in the immer docs](https://immerjs.github.io/immer/docs/pitfalls) . Most importantly, **you need to ensure that you either mutate the `state` argument or return a new state, _but not both_**. For example, the following reducer would throw an exception if a `toggleTodo` action is passed:
121152

122153
```js
123154
const todosReducer = createReducer([], {
@@ -136,11 +167,163 @@ const todosReducer = createReducer([], {
136167
})
137168
```
138169

170+
## The "builder callback" API
171+
172+
Instead of using a plain object as an argument to `createReducer`, you can also provide a "builder callback" function that receives an `ActionReducerMapBuilder` instance:
173+
174+
```typescript
175+
const increment = createAction('increment')
176+
const decrement = createAction('decrement')
177+
createReducer(0, builder =>
178+
builder
179+
.addCase(increment, (state, action) => {
180+
// action is inferred correctly here
181+
})
182+
// You can chain calls, or have separate `builder.addCase()` lines each time
183+
.addCase(decrement, (state, action) => {})
184+
// You can match a range of action types
185+
.addMatcher(
186+
action => action.endsWith('rejected'),
187+
(state, action) => {}
188+
)
189+
// and provide a default case if no other handlers matched
190+
.addDefaultCase((state, action) => {})
191+
)
192+
```
193+
194+
While the object syntax is shorter, the builder callback syntax allows adding multiple forms of reducers. It also provides better type inference, as passing a plain object full of reducer functions cannot infer their types correctly in this case.
195+
196+
We recommend using this API if stricter type safety is necessary when defining reducer argument objects.
197+
198+
### `builder.addCase`
199+
200+
Adds a case reducer to handle a single exact action type. The first argument may be either a plain action type string, or an action creator generated by [`createAction`](./createAction.md) that can be used to determine the action type. The second argument is the actual case reducer function.
201+
202+
All calls to `builder.addCase` must come before any calls to `builder.addMatcher` or `builder.addDefaultCase`.
203+
204+
### `builder.addMatcher`
205+
206+
`builder.addMatcher` allows you to match your reducer against your own filter function instead of only the `action.type` property.
207+
This allows for a lot of generic behaviour, so you could for example write a "generic loading tracker" state based on an approach like this:
208+
209+
```js
210+
const initialState = {}
211+
const resetAction = createAction('reset-tracked-loading-state')
212+
const reducer = createReducer(initialState, builder => {
213+
builder
214+
.addCase(resetAction, () => initialState)
215+
.addMatcher(
216+
action => action.type.endsWith('/pending') && 'requestId' in action.meta,
217+
(state, action) => {
218+
state[action.meta.requestId] = 'pending'
219+
}
220+
)
221+
.addMatcher(
222+
action => action.type.endsWith('/rejected') && 'requestId' in action.meta,
223+
(state, action) => {
224+
state[action.meta.requestId] = 'rejected'
225+
}
226+
)
227+
.addMatcher(
228+
action =>
229+
action.type.endsWith('/fulfilled') && 'requestId' in action.meta,
230+
(state, action) => {
231+
state[action.meta.requestId] = 'fulfilled'
232+
}
233+
)
234+
})
235+
```
236+
237+
Note that _all_ matching matcher reducers will execute in order, even if a case reducer has already executed.
238+
239+
### `builder.addDefaultCase`
240+
241+
`builder.addDefaultCase` allows you to add a "default" reducer that will execute if no case reducer or matcher reducer was executed.
242+
243+
```js
244+
const reducer = createReducer(initialState, builder => {
245+
builder
246+
// .addCase
247+
// ...
248+
// .addMatcher
249+
// ...
250+
.addDefaultCase((state, action) => {
251+
state.otherActions++
252+
})
253+
})
254+
```
255+
256+
### Matchers and Default Cases as Arguments
257+
258+
The most readable approach to define matcher cases and default cases is by using the `builder.addMatcher` and `builder.addDefaultCase` methods described above, but it is also possible to use these with the object notation by passing an array of `{matcher, reducer}` objects as the third argument, and a default case reducer as the fourth argument:
259+
260+
```js
261+
const isStringPayloadAction = action => typeof action.payload === 'string'
262+
263+
const lengthOfAllStringsReducer = createReducer(
264+
// initial state
265+
{ strLen: 0, nonStringActions: 0 },
266+
// normal reducers
267+
{
268+
/*...*/
269+
},
270+
// array of matcher reducers
271+
[
272+
{
273+
matcher: isStringPayloadAction,
274+
reducer(state, action) {
275+
state.strLen += action.payload.length
276+
}
277+
}
278+
],
279+
// default reducer
280+
state => {
281+
state.nonStringActions++
282+
}
283+
)
284+
```
285+
286+
## Multiple Case Reducer Execution
287+
288+
Originally, `createReducer` always matched a given action type to a single case reducer, and only that one case reducer would execute for a given action.
289+
290+
The action matcher support changes that behavior, as multiple matchers may handle a single action.
291+
292+
For any dispatched action, the behavior is:
293+
294+
- If there is an exact match for the action type, the corresponding case reducer will execute first
295+
- Any matchers that return `true` will execute in the order they were defined
296+
- If a default case reducer is provided, and _no_ case or matcher reducers ran, the default case reducer will execute
297+
- If no case or matcher reducers ran, the original existing state value will be returned unchanged
298+
299+
The executing reducers form a pipeline, and each of them will receive the output of the previous reducer:
300+
301+
```js
302+
const reducer = createReducer(0, builder => {
303+
builder
304+
.addCase('increment', state => state + 1)
305+
.addMatcher(
306+
action => action.type.startsWith('i'),
307+
state => state * 5
308+
)
309+
.addMatcher(
310+
action => action.type.endsWith('t'),
311+
state => state + 2
312+
)
313+
})
314+
315+
console.log(reducer(0, { type: 'increment' }))
316+
// Returns 7, as the 'increment' case and both matchers all ran in sequence:
317+
// - case 'increment": 0 => 1
318+
// - matcher starts with 'i': 1 => 5
319+
// - matcher ends with 't': 5 => 7
320+
```
321+
139322
## Debugging your state
140323

141324
It's very common for a developer to call `console.log(state)` during the development process. However, browsers display Proxies in a format that is hard to read, which can make console logging of Immer-based state difficult.
142325

143-
When using either `createSlice` or `createReducer`, you may use the [`current`](./otherExports#current.md) utility that we re-export from the [`immer` library](https://immerjs.github.io/immer). This utility creates a separate plain copy of the current Immer `Draft` state value, which can then be logged for viewing as normal.
326+
When using either `createSlice` or `createReducer`, you may use the [`current`](./otherExports#current.md) utility that we re-export from the [`immer` library](https://immerjs.github.io/immer/docs/current). This utility creates a separate plain copy of the current Immer `Draft` state value, which can then be logged for viewing as normal.
144327

145328
```ts
146329
// todosSlice.js

0 commit comments

Comments
 (0)