Repository: reduxjs/redux-toolkit
Pull request #513 (2020-04-19 12:47:47)

allow to skip AsyncThunks using a condition callback
This is my first attempt at adding a `condition` callback so that people can skip `asyncThunk` execution before it even enters `pending` state, depending on `arg`, `getState()` and `extra`.


(Please don't merge this before #502 and #512, I guess order of merging might be of importance for merge conflicts here :laughing:)

netlify[bot]: Deploy preview for *redux-starter-kit-docs* ready!

Built with commit 14936096cc6ced7449af5e68e746c2b6be3a44f3

https://deploy-preview-513--redux-starter-kit-docs.netlify.app
codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/reduxjs/redux-toolkit/pr/513/builds/20774) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 14936096cc6ced7449af5e68e746c2b6be3a44f3:

|Sandbox| Source |
|--|--|
|[competent-villani-gud72](https://codesandbox.io/s/competent-villani-gud72)| Configuration |
|[friendly-brown-f7uos](https://codesandbox.io/s/friendly-brown-f7uos)| Configuration |
|[upbeat-wozniak-nnx72](https://codesandbox.io/s/upbeat-wozniak-nnx72)| Configuration |


Patch:
diff --git a/docs/api/createAsyncThunk.md b/docs/api/createAsyncThunk.md
index 9e6c7a2c..7294af03 100644
--- a/docs/api/createAsyncThunk.md
+++ b/docs/api/createAsyncThunk.md
@@ -9,10 +9,12 @@ hide_title: true
 
 ## Overview
 
-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.
+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.
 
 This abstracts the standard recommended approach for handling async request lifecycles.
 
+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.
+
 Sample usage:
 
 ```js {5-11,22-25,30}
@@ -50,7 +52,7 @@ dispatch(fetchUserById(123))
 
 ## Parameters
 
-`createAsyncThunk` accepts two parameters: a string action `type` value, and a `payloadCreator` callback.
+`createAsyncThunk` accepts three parameters: a string action `type` value, a `payloadCreator` callback, and an `options` object.
 
 ### `type`
 
@@ -81,10 +83,24 @@ The `payloadCreator` function will be called with two arguments:
 
 The logic in the `payloadCreator` function may use any of these values as needed to calculate the result.
 
+### Options
+
+An object with the following optional fields:
+
+- `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.
+- `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`.
+
 ## Return Value
 
 `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.
 
+Using the `fetchUserById` example above, `createAsyncThunk` will generate four functions:
+
+- `fetchUserById`, the thunk action creator that kicks off the async payload callback you wrote
+  - `fetchUserById.pending`, an action creator that dispatches an `'users/fetchByIdStatus/pending'` action
+  - `fetchUserById.fulfilled`, an action creator that dispatches an `'users/fetchByIdStatus/fulfilled'` action
+  - `fetchUserById.rejected`, an action creator that dispatches an `'users/fetchByIdStatus/rejected'` action
+
 When dispatched, the thunk will:
 
 - dispatch the `pending` action
@@ -135,6 +151,7 @@ interface RejectedAction<ThunkArg> {
     requestId: string
     arg: ThunkArg
     aborted: boolean
+    condition: bolean
   }
 }
 
@@ -231,6 +248,34 @@ const onClick = () => {
 
 ## Cancellation
 
+### Canceling Before Execution
+
+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:
+
+```js
+const fetchUserById = createAsyncThunk(
+  'users/fetchByIdStatus',
+  async (userId, thunkAPI) => {
+    const response = await userAPI.fetchById(userId)
+    return response.data
+  },
+  {
+    condition: (userId, { getState, extra }) => {
+      const { users } = getState()
+      const fetchStatus = users.requests[userId]
+      if (fetchStatus === 'fulfilled' || fetchStatus === 'loading') {
+        // Already fetched or in progress, don't need to re-fetch
+        return false
+      }
+    }
+  }
+)
+```
+
+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}`.
+
+### Canceling While Running
+
 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))`.
 
 A real-life example of that would look like this:
diff --git a/etc/redux-toolkit.api.md b/etc/redux-toolkit.api.md
index fedfb949..9b88eec2 100644
--- a/etc/redux-toolkit.api.md
+++ b/etc/redux-toolkit.api.md
@@ -60,7 +60,15 @@ export interface ActionReducerMapBuilder<State> {
 export type Actions<T extends keyof any = string> = Record<T, Action>;
 
 // @public
-export type AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig extends AsyncThunkConfig> = (dispatch: GetDispatch<ThunkApiConfig>, getState: () => GetState<ThunkApiConfig>, extra: GetExtra<ThunkApiConfig>) => Promise<AsyncThunkReturnValue<ThunkArg, Returned, GetRejectValue<ThunkApiConfig>>> & {
+export type AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig extends AsyncThunkConfig> = (dispatch: GetDispatch<ThunkApiConfig>, getState: () => GetState<ThunkApiConfig>, extra: GetExtra<ThunkApiConfig>) => Promise<PayloadAction<Returned, string, {
+    arg: ThunkArg;
+    requestId: string;
+}> | PayloadAction<undefined | GetRejectValue<ThunkApiConfig>, string, {
+    arg: ThunkArg;
+    requestId: string;
+    aborted: boolean;
+    condition: boolean;
+}, SerializedError>> & {
     abort(reason?: string): void;
 };
 
@@ -116,7 +124,7 @@ export function createAction<P = void, T extends string = string>(type: T): Payl
 export function createAction<PA extends PrepareAction<any>, T extends string = string>(type: T, prepareAction: PA): PayloadActionCreator<ReturnType<PA>['payload'], T, PA>;
 
 // @public (undocumented)
-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>> & {
+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>> & {
     pending: ActionCreatorWithPreparedPayload<[string, ThunkArg], undefined, string, never, {
         arg: ThunkArg;
         requestId: string;
@@ -125,6 +133,7 @@ export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig exten
         arg: ThunkArg;
         requestId: string;
         aborted: boolean;
+        condition: boolean;
     }>;
     fulfilled: ActionCreatorWithPreparedPayload<[Returned, string, ThunkArg], Returned, string, never, {
         arg: ThunkArg;
diff --git a/src/createAsyncThunk.ts b/src/createAsyncThunk.ts
index 924826e5..ea0cc79c 100644
--- a/src/createAsyncThunk.ts
+++ b/src/createAsyncThunk.ts
@@ -130,14 +130,6 @@ export type AsyncThunkPayloadCreator<
   thunkAPI: GetThunkAPI<ThunkApiConfig>
 ) => AsyncThunkPayloadCreatorReturnValue<Returned, ThunkApiConfig>
 
-type AsyncThunkReturnValue<ThunkArg, FulfilledValue, RejectedValue> =
-  | PayloadAction<FulfilledValue, string, { arg: ThunkArg; requestId: string }>
-  | PayloadAction<
-      undefined | RejectedValue,
-      string,
-      { arg: ThunkArg; requestId: string; aborted: boolean },
-      SerializedError
-    >
 /**
  * A ThunkAction created by `createAsyncThunk`.
  * Dispatching it returns a Promise for either a
@@ -156,7 +148,18 @@ export type AsyncThunkAction<
   getState: () => GetState<ThunkApiConfig>,
   extra: GetExtra<ThunkApiConfig>
 ) => Promise<
-  AsyncThunkReturnValue<ThunkArg, Returned, GetRejectValue<ThunkApiConfig>>
+  | PayloadAction<Returned, string, { arg: ThunkArg; requestId: string }>
+  | PayloadAction<
+      undefined | GetRejectValue<ThunkApiConfig>,
+      string,
+      {
+        arg: ThunkArg
+        requestId: string
+        aborted: boolean
+        condition: boolean
+      },
+      SerializedError
+    >
 > & {
   abort(reason?: string): void
 }
@@ -188,10 +191,35 @@ type AsyncThunkActionCreator<
     : (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
 >
 
+interface AsyncThunkOptions<
+  ThunkArg = void,
+  ThunkApiConfig extends AsyncThunkConfig = {}
+> {
+  /**
+   * A method to control whether the asyncThunk should be executed. Has access to the
+   * `arg`, `api.getState()` and `api.extra` arguments.
+   *
+   * @returns `true` if the asyncThunk should be executed, `false` if it should be skipped
+   */
+  condition?(
+    arg: ThunkArg,
+    api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
+  ): boolean
+  /**
+   * If `condition` returns `false`, the asyncThunk will be skipped.
+   * This option allows you to control whether a `rejected` action with `meta.condition == false`
+   * will be dispatched or not.
+   *
+   * @default `false`
+   */
+  dispatchConditionRejection?: boolean
+}
+
 /**
  *
  * @param type
  * @param payloadCreator
+ * @param options
  *
  * @public
  */
@@ -201,7 +229,14 @@ export function createAsyncThunk<
   ThunkApiConfig extends AsyncThunkConfig = {}
 >(
   type: string,
-  payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>
+  payloadCreator: (
+    arg: ThunkArg,
+    thunkAPI: GetThunkAPI<ThunkApiConfig>
+  ) =>
+    | Promise<Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>>
+    | Returned
+    | RejectWithValue<GetRejectValue<ThunkApiConfig>>,
+  options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
 ) {
   type RejectedValue = GetRejectValue<ThunkApiConfig>
 
@@ -234,13 +269,15 @@ export function createAsyncThunk<
       payload?: RejectedValue
     ) => {
       const aborted = !!error && error.name === 'AbortError'
+      const condition = !!error && error.name === 'ConditionError'
       return {
         payload,
         error: miniSerializeError(error || 'Rejected'),
         meta: {
           arg,
           requestId,
-          aborted
+          aborted,
+          condition
         }
       }
     }
@@ -297,6 +334,16 @@ If you want to use the AbortController to react to \`abort\` events, please cons
       const promise = (async function() {
         let finalAction: ReturnType<typeof fulfilled | typeof rejected>
         try {
+          if (
+            options &&
+            options.condition &&
+            options.condition(arg, { getState, extra }) === false
+          ) {
+            throw {
+              name: 'ConditionError',
+              message: 'Aborted due to condition callback returning false.'
+            }
+          }
           dispatch(pending(requestId, arg))
           finalAction = await Promise.race([
             abortedPromise,
@@ -326,7 +373,15 @@ If you want to use the AbortController to react to \`abort\` events, please cons
         // per https://twitter.com/dan_abramov/status/770914221638942720
         // and https://redux-toolkit.js.org/tutorials/advanced-tutorial#async-error-handling-logic-in-thunks
 
-        dispatch(finalAction)
+        const skipDispatch =
+          options &&
+          !options.dispatchConditionRejection &&
+          rejected.match(finalAction) &&
+          finalAction.meta.condition
+
+        if (!skipDispatch) {
+          dispatch(finalAction)
+        }
         return finalAction
       })()
       return Object.assign(promise, { abort })


Test patch:
diff --git a/src/createAsyncThunk.test.ts b/src/createAsyncThunk.test.ts
index dd50f78d..38644b83 100644
--- a/src/createAsyncThunk.test.ts
+++ b/src/createAsyncThunk.test.ts
@@ -476,3 +476,96 @@ test('non-serializable arguments are ignored by serializableStateInvariantMiddle
   expect(getLog().log).toMatchInlineSnapshot(`""`)
   restore()
 })
+
+describe('conditional skipping of asyncThunks', () => {
+  const arg = {}
+  const getState = jest.fn(() => ({}))
+  const dispatch = jest.fn((x: any) => x)
+  const payloadCreator = jest.fn((x: typeof arg) => 10)
+  const condition = jest.fn(() => false)
+  const extra = {}
+
+  beforeEach(() => {
+    getState.mockClear()
+    dispatch.mockClear()
+    payloadCreator.mockClear()
+    condition.mockClear()
+  })
+
+  test('returning false from condition skips payloadCreator and returns a rejected action', async () => {
+    const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
+    const result = await asyncThunk(arg)(dispatch, getState, extra)
+
+    expect(condition).toHaveBeenCalled()
+    expect(payloadCreator).not.toHaveBeenCalled()
+    expect(asyncThunk.rejected.match(result)).toBe(true)
+    expect((result as any).meta.condition).toBe(true)
+  })
+
+  test('return falsy from condition does not skip payload creator', async () => {
+    // Override TS's expectation that this is a boolean
+    condition.mockReturnValueOnce((undefined as unknown) as boolean)
+    const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
+    const result = await asyncThunk(arg)(dispatch, getState, extra)
+
+    expect(condition).toHaveBeenCalled()
+    expect(payloadCreator).toHaveBeenCalled()
+    expect(asyncThunk.fulfilled.match(result)).toBe(true)
+    expect(result.payload).toBe(10)
+  })
+
+  test('returning true from condition executes payloadCreator', async () => {
+    condition.mockReturnValueOnce(true)
+    const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
+    const result = await asyncThunk(arg)(dispatch, getState, extra)
+
+    expect(condition).toHaveBeenCalled()
+    expect(payloadCreator).toHaveBeenCalled()
+    expect(asyncThunk.fulfilled.match(result)).toBe(true)
+    expect(result.payload).toBe(10)
+  })
+
+  test('condition is called with arg, getState and extra', async () => {
+    const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
+    await asyncThunk(arg)(dispatch, getState, extra)
+
+    expect(condition).toHaveBeenCalledTimes(1)
+    expect(condition).toHaveBeenLastCalledWith(
+      arg,
+      expect.objectContaining({ getState, extra })
+    )
+  })
+
+  test('rejected action is not dispatched by default', async () => {
+    const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
+    await asyncThunk(arg)(dispatch, getState, extra)
+
+    expect(dispatch).toHaveBeenCalledTimes(0)
+  })
+
+  test('rejected action can be dispatched via option', async () => {
+    const asyncThunk = createAsyncThunk('test', payloadCreator, {
+      condition,
+      dispatchConditionRejection: true
+    })
+    await asyncThunk(arg)(dispatch, getState, extra)
+
+    expect(dispatch).toHaveBeenCalledTimes(1)
+    expect(dispatch).toHaveBeenLastCalledWith(
+      expect.objectContaining({
+        error: {
+          message: 'Aborted due to condition callback returning false.',
+          name: 'ConditionError'
+        },
+        meta: {
+          aborted: false,
+          arg: arg,
+          condition: true,
+          requestId: expect.stringContaining('')
+        },
+        payload: undefined,
+        type: 'test/rejected'
+      })
+    )
+  })
+})