Skip to content

Commit 2b1fca3

Browse files
allow to skip AsyncThunks using a condition callback (#513)
* allow to skip AsyncThunks using a condition callback * Only skip thunk if condition returns literal `false` * Add cancel-before-execute explanation * add condition * inline AsyncThunkReturnValue * change default behaviour of dispatchConditionRejection * Document createAsyncThunk options Co-authored-by: Mark Erikson <mark@isquaredsoftware.com>
1 parent 74a0249 commit 2b1fca3

File tree

4 files changed

+218
-16
lines changed

4 files changed

+218
-16
lines changed

docs/api/createAsyncThunk.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ hide_title: true
99

1010
## Overview
1111

12-
A function that accepts a Redux action type string and a callback function that should return a promise. It generates promise lifecycle action types based on the provided action type, and returns a thunk action creator that will run the promise callback and dispatch the lifecycle actions based on the returned promise.
12+
A function that accepts a Redux action type string and a callback function that should return a promise. It generates promise lifecycle action types based on the action type prefix that you pass in, and returns a thunk action creator that will run the promise callback and dispatch the lifecycle actions based on the returned promise.
1313

1414
This abstracts the standard recommended approach for handling async request lifecycles.
1515

16+
It does not generate any reducer functions, since it does not know what data you're fetching, how you want to track loading state, or how the data you return needs to be processed. You should write your own reducer logic that handles these actions, with whatever loading state and processing logic is appropriate for your own app.
17+
1618
Sample usage:
1719

1820
```js {5-11,22-25,30}
@@ -50,7 +52,7 @@ dispatch(fetchUserById(123))
5052

5153
## Parameters
5254

53-
`createAsyncThunk` accepts two parameters: a string action `type` value, and a `payloadCreator` callback.
55+
`createAsyncThunk` accepts three parameters: a string action `type` value, a `payloadCreator` callback, and an `options` object.
5456

5557
### `type`
5658

@@ -81,10 +83,24 @@ The `payloadCreator` function will be called with two arguments:
8183

8284
The logic in the `payloadCreator` function may use any of these values as needed to calculate the result.
8385

86+
### Options
87+
88+
An object with the following optional fields:
89+
90+
- `condition`: a callback that can be used to skip execution of the payload creator and all action dispatches, if desired. See [Canceling Before Execution](#canceling-before-execution) for a complete description.
91+
- `dispatchConditionRejection`: if `condition()` returns `false`, the default behavior is that no actions will be dispatched at all. If you still want a "rejected" action to be dispatched when the thunk was canceled, set this flag to `true`.
92+
8493
## Return Value
8594

8695
`createAsyncThunk` returns a standard Redux thunk action creator. The thunk action creator function will have plain action creators for the `pending`, `fulfilled`, and `rejected` cases attached as nested fields.
8796

97+
Using the `fetchUserById` example above, `createAsyncThunk` will generate four functions:
98+
99+
- `fetchUserById`, the thunk action creator that kicks off the async payload callback you wrote
100+
- `fetchUserById.pending`, an action creator that dispatches an `'users/fetchByIdStatus/pending'` action
101+
- `fetchUserById.fulfilled`, an action creator that dispatches an `'users/fetchByIdStatus/fulfilled'` action
102+
- `fetchUserById.rejected`, an action creator that dispatches an `'users/fetchByIdStatus/rejected'` action
103+
88104
When dispatched, the thunk will:
89105

90106
- dispatch the `pending` action
@@ -135,6 +151,7 @@ interface RejectedAction<ThunkArg> {
135151
requestId: string
136152
arg: ThunkArg
137153
aborted: boolean
154+
condition: bolean
138155
}
139156
}
140157

@@ -231,6 +248,34 @@ const onClick = () => {
231248

232249
## Cancellation
233250

251+
### Canceling Before Execution
252+
253+
If you need to cancel a thunk before the payload creator is called, you may provide a `condition` callback as an option after the payload creator. The callback will receive the thunk argument and an object with `{getState, extra}` as parameters, and use those to decide whether to continue or not. If the execution should be canceled, the `condition` callback should return a literal `false` value:
254+
255+
```js
256+
const fetchUserById = createAsyncThunk(
257+
'users/fetchByIdStatus',
258+
async (userId, thunkAPI) => {
259+
const response = await userAPI.fetchById(userId)
260+
return response.data
261+
},
262+
{
263+
condition: (userId, { getState, extra }) => {
264+
const { users } = getState()
265+
const fetchStatus = users.requests[userId]
266+
if (fetchStatus === 'fulfilled' || fetchStatus === 'loading') {
267+
// Already fetched or in progress, don't need to re-fetch
268+
return false
269+
}
270+
}
271+
}
272+
)
273+
```
274+
275+
If `condition()` returns `false`, the default behavior is that no actions will be dispatched at all. If you still want a "rejected" action to be dispatched when the thunk was canceled, pass in `{condition, dispatchConditionRejection: true}`.
276+
277+
### Canceling While Running
278+
234279
If you want to cancel your running thunk before it has finished, you can use the `abort` method of the promise returned by `dispatch(fetchUserById(userId))`.
235280

236281
A real-life example of that would look like this:

etc/redux-toolkit.api.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,15 @@ export interface ActionReducerMapBuilder<State> {
6060
export type Actions<T extends keyof any = string> = Record<T, Action>;
6161

6262
// @public
63-
export type AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig extends AsyncThunkConfig> = (dispatch: GetDispatch<ThunkApiConfig>, getState: () => GetState<ThunkApiConfig>, extra: GetExtra<ThunkApiConfig>) => Promise<AsyncThunkReturnValue<ThunkArg, Returned, GetRejectValue<ThunkApiConfig>>> & {
63+
export type AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig extends AsyncThunkConfig> = (dispatch: GetDispatch<ThunkApiConfig>, getState: () => GetState<ThunkApiConfig>, extra: GetExtra<ThunkApiConfig>) => Promise<PayloadAction<Returned, string, {
64+
arg: ThunkArg;
65+
requestId: string;
66+
}> | PayloadAction<undefined | GetRejectValue<ThunkApiConfig>, string, {
67+
arg: ThunkArg;
68+
requestId: string;
69+
aborted: boolean;
70+
condition: boolean;
71+
}, SerializedError>> & {
6472
abort(reason?: string): void;
6573
};
6674

@@ -116,7 +124,7 @@ export function createAction<P = void, T extends string = string>(type: T): Payl
116124
export function createAction<PA extends PrepareAction<any>, T extends string = string>(type: T, prepareAction: PA): PayloadActionCreator<ReturnType<PA>['payload'], T, PA>;
117125

118126
// @public (undocumented)
119-
export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig extends AsyncThunkConfig = {}>(type: string, payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>): IsAny<ThunkArg, (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>, unknown extends ThunkArg ? (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : [ThunkArg] extends [void] | [undefined] ? () => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : [void] extends [ThunkArg] ? (arg?: ThunkArg | undefined) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : [undefined] extends [ThunkArg] ? (arg?: ThunkArg | undefined) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>> & {
127+
export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig extends AsyncThunkConfig = {}>(type: string, payloadCreator: (arg: ThunkArg, thunkAPI: GetThunkAPI<ThunkApiConfig>) => Promise<Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>> | Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>, options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>): IsAny<ThunkArg, (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>, unknown extends ThunkArg ? (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : [ThunkArg] extends [void] | [undefined] ? () => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : [void] extends [ThunkArg] ? (arg?: ThunkArg | undefined) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : [undefined] extends [ThunkArg] ? (arg?: ThunkArg | undefined) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>> & {
120128
pending: ActionCreatorWithPreparedPayload<[string, ThunkArg], undefined, string, never, {
121129
arg: ThunkArg;
122130
requestId: string;
@@ -125,6 +133,7 @@ export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig exten
125133
arg: ThunkArg;
126134
requestId: string;
127135
aborted: boolean;
136+
condition: boolean;
128137
}>;
129138
fulfilled: ActionCreatorWithPreparedPayload<[Returned, string, ThunkArg], Returned, string, never, {
130139
arg: ThunkArg;

src/createAsyncThunk.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,3 +476,96 @@ test('non-serializable arguments are ignored by serializableStateInvariantMiddle
476476
expect(getLog().log).toMatchInlineSnapshot(`""`)
477477
restore()
478478
})
479+
480+
describe('conditional skipping of asyncThunks', () => {
481+
const arg = {}
482+
const getState = jest.fn(() => ({}))
483+
const dispatch = jest.fn((x: any) => x)
484+
const payloadCreator = jest.fn((x: typeof arg) => 10)
485+
const condition = jest.fn(() => false)
486+
const extra = {}
487+
488+
beforeEach(() => {
489+
getState.mockClear()
490+
dispatch.mockClear()
491+
payloadCreator.mockClear()
492+
condition.mockClear()
493+
})
494+
495+
test('returning false from condition skips payloadCreator and returns a rejected action', async () => {
496+
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
497+
const result = await asyncThunk(arg)(dispatch, getState, extra)
498+
499+
expect(condition).toHaveBeenCalled()
500+
expect(payloadCreator).not.toHaveBeenCalled()
501+
expect(asyncThunk.rejected.match(result)).toBe(true)
502+
expect((result as any).meta.condition).toBe(true)
503+
})
504+
505+
test('return falsy from condition does not skip payload creator', async () => {
506+
// Override TS's expectation that this is a boolean
507+
condition.mockReturnValueOnce((undefined as unknown) as boolean)
508+
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
509+
const result = await asyncThunk(arg)(dispatch, getState, extra)
510+
511+
expect(condition).toHaveBeenCalled()
512+
expect(payloadCreator).toHaveBeenCalled()
513+
expect(asyncThunk.fulfilled.match(result)).toBe(true)
514+
expect(result.payload).toBe(10)
515+
})
516+
517+
test('returning true from condition executes payloadCreator', async () => {
518+
condition.mockReturnValueOnce(true)
519+
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
520+
const result = await asyncThunk(arg)(dispatch, getState, extra)
521+
522+
expect(condition).toHaveBeenCalled()
523+
expect(payloadCreator).toHaveBeenCalled()
524+
expect(asyncThunk.fulfilled.match(result)).toBe(true)
525+
expect(result.payload).toBe(10)
526+
})
527+
528+
test('condition is called with arg, getState and extra', async () => {
529+
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
530+
await asyncThunk(arg)(dispatch, getState, extra)
531+
532+
expect(condition).toHaveBeenCalledTimes(1)
533+
expect(condition).toHaveBeenLastCalledWith(
534+
arg,
535+
expect.objectContaining({ getState, extra })
536+
)
537+
})
538+
539+
test('rejected action is not dispatched by default', async () => {
540+
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
541+
await asyncThunk(arg)(dispatch, getState, extra)
542+
543+
expect(dispatch).toHaveBeenCalledTimes(0)
544+
})
545+
546+
test('rejected action can be dispatched via option', async () => {
547+
const asyncThunk = createAsyncThunk('test', payloadCreator, {
548+
condition,
549+
dispatchConditionRejection: true
550+
})
551+
await asyncThunk(arg)(dispatch, getState, extra)
552+
553+
expect(dispatch).toHaveBeenCalledTimes(1)
554+
expect(dispatch).toHaveBeenLastCalledWith(
555+
expect.objectContaining({
556+
error: {
557+
message: 'Aborted due to condition callback returning false.',
558+
name: 'ConditionError'
559+
},
560+
meta: {
561+
aborted: false,
562+
arg: arg,
563+
condition: true,
564+
requestId: expect.stringContaining('')
565+
},
566+
payload: undefined,
567+
type: 'test/rejected'
568+
})
569+
)
570+
})
571+
})

src/createAsyncThunk.ts

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,6 @@ export type AsyncThunkPayloadCreator<
130130
thunkAPI: GetThunkAPI<ThunkApiConfig>
131131
) => AsyncThunkPayloadCreatorReturnValue<Returned, ThunkApiConfig>
132132

133-
type AsyncThunkReturnValue<ThunkArg, FulfilledValue, RejectedValue> =
134-
| PayloadAction<FulfilledValue, string, { arg: ThunkArg; requestId: string }>
135-
| PayloadAction<
136-
undefined | RejectedValue,
137-
string,
138-
{ arg: ThunkArg; requestId: string; aborted: boolean },
139-
SerializedError
140-
>
141133
/**
142134
* A ThunkAction created by `createAsyncThunk`.
143135
* Dispatching it returns a Promise for either a
@@ -156,7 +148,18 @@ export type AsyncThunkAction<
156148
getState: () => GetState<ThunkApiConfig>,
157149
extra: GetExtra<ThunkApiConfig>
158150
) => Promise<
159-
AsyncThunkReturnValue<ThunkArg, Returned, GetRejectValue<ThunkApiConfig>>
151+
| PayloadAction<Returned, string, { arg: ThunkArg; requestId: string }>
152+
| PayloadAction<
153+
undefined | GetRejectValue<ThunkApiConfig>,
154+
string,
155+
{
156+
arg: ThunkArg
157+
requestId: string
158+
aborted: boolean
159+
condition: boolean
160+
},
161+
SerializedError
162+
>
160163
> & {
161164
abort(reason?: string): void
162165
}
@@ -188,10 +191,35 @@ type AsyncThunkActionCreator<
188191
: (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
189192
>
190193

194+
interface AsyncThunkOptions<
195+
ThunkArg = void,
196+
ThunkApiConfig extends AsyncThunkConfig = {}
197+
> {
198+
/**
199+
* A method to control whether the asyncThunk should be executed. Has access to the
200+
* `arg`, `api.getState()` and `api.extra` arguments.
201+
*
202+
* @returns `true` if the asyncThunk should be executed, `false` if it should be skipped
203+
*/
204+
condition?(
205+
arg: ThunkArg,
206+
api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
207+
): boolean
208+
/**
209+
* If `condition` returns `false`, the asyncThunk will be skipped.
210+
* This option allows you to control whether a `rejected` action with `meta.condition == false`
211+
* will be dispatched or not.
212+
*
213+
* @default `false`
214+
*/
215+
dispatchConditionRejection?: boolean
216+
}
217+
191218
/**
192219
*
193220
* @param type
194221
* @param payloadCreator
222+
* @param options
195223
*
196224
* @public
197225
*/
@@ -201,7 +229,14 @@ export function createAsyncThunk<
201229
ThunkApiConfig extends AsyncThunkConfig = {}
202230
>(
203231
type: string,
204-
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>
232+
payloadCreator: (
233+
arg: ThunkArg,
234+
thunkAPI: GetThunkAPI<ThunkApiConfig>
235+
) =>
236+
| Promise<Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>>
237+
| Returned
238+
| RejectWithValue<GetRejectValue<ThunkApiConfig>>,
239+
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
205240
) {
206241
type RejectedValue = GetRejectValue<ThunkApiConfig>
207242

@@ -234,13 +269,15 @@ export function createAsyncThunk<
234269
payload?: RejectedValue
235270
) => {
236271
const aborted = !!error && error.name === 'AbortError'
272+
const condition = !!error && error.name === 'ConditionError'
237273
return {
238274
payload,
239275
error: miniSerializeError(error || 'Rejected'),
240276
meta: {
241277
arg,
242278
requestId,
243-
aborted
279+
aborted,
280+
condition
244281
}
245282
}
246283
}
@@ -297,6 +334,16 @@ If you want to use the AbortController to react to \`abort\` events, please cons
297334
const promise = (async function() {
298335
let finalAction: ReturnType<typeof fulfilled | typeof rejected>
299336
try {
337+
if (
338+
options &&
339+
options.condition &&
340+
options.condition(arg, { getState, extra }) === false
341+
) {
342+
throw {
343+
name: 'ConditionError',
344+
message: 'Aborted due to condition callback returning false.'
345+
}
346+
}
300347
dispatch(pending(requestId, arg))
301348
finalAction = await Promise.race([
302349
abortedPromise,
@@ -326,7 +373,15 @@ If you want to use the AbortController to react to \`abort\` events, please cons
326373
// per https://twitter.com/dan_abramov/status/770914221638942720
327374
// and https://redux-toolkit.js.org/tutorials/advanced-tutorial#async-error-handling-logic-in-thunks
328375

329-
dispatch(finalAction)
376+
const skipDispatch =
377+
options &&
378+
!options.dispatchConditionRejection &&
379+
rejected.match(finalAction) &&
380+
finalAction.meta.condition
381+
382+
if (!skipDispatch) {
383+
dispatch(finalAction)
384+
}
330385
return finalAction
331386
})()
332387
return Object.assign(promise, { abort })

0 commit comments

Comments
 (0)