Repository: reduxjs/redux-toolkit
Pull request #362 (2020-02-15 19:54:32)

add .abort() to the createAsyncThunk thunkAction
It's a bit... async, sorry - I don't think this is much simpler to solve.

The tests should give you a pretty good idea of what's actually happening.

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

Built with commit b6fbefcf461d687c59a0855dc5b9ff562951a3af

https://deploy-preview-362--redux-starter-kit-docs.netlify.com
markerikson: Yipes. Yeah, getting complicated.

It's almost like there are other asynchronous logic abstractions that are perhaps better meant for cancellation or something!
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/362/builds/11092) or the icon next to each commit SHA.
phryneas: > It's almost like there are other asynchronous logic abstractions that are perhaps better meant for cancellation or something!

Well, definitely not promises :D

This is what the fetch API uses, so it would fit most use cases I guess - and actually using it is pretty straightforward, too (see the tests).
markerikson: Awright, good enough for now.  Great work!

Patch:
diff --git a/etc/redux-toolkit.api.md b/etc/redux-toolkit.api.md
index 92233aa9..dfc3d17c 100644
--- a/etc/redux-toolkit.api.md
+++ b/etc/redux-toolkit.api.md
@@ -102,32 +102,37 @@ 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>;
 
 // @alpha (undocumented)
-export function createAsyncThunk<ActionType extends string, Returned, ActionParams = void, TA extends AsyncThunksArgs<any, any, any> = AsyncThunksArgs<unknown, unknown, Dispatch>>(type: ActionType, payloadCreator: (args: ActionParams, thunkArgs: TA) => Promise<Returned> | Returned): ((args: ActionParams) => (dispatch: TA["dispatch"], getState: TA["getState"], extra: TA["extra"]) => Promise<import("./createAction").PayloadAction<Returned, string, {
+export function createAsyncThunk<ActionType extends string, Returned, ActionParams = void, TA extends AsyncThunksArgs<any, any, any> = AsyncThunksArgs<unknown, unknown, Dispatch>>(type: ActionType, payloadCreator: (args: ActionParams, thunkArgs: TA) => Promise<Returned> | Returned): ((args: ActionParams) => (dispatch: TA["dispatch"], getState: TA["getState"], extra: TA["extra"]) => Promise<import("./createAction").PayloadAction<undefined, string, {
     args: ActionParams;
     requestId: string;
-}, never> | import("./createAction").PayloadAction<undefined, string, {
+} | {
+    aborted: boolean;
+    abortReason: string;
     args: ActionParams;
     requestId: string;
-}, Error>>) & {
+}, any> | import("./createAction").PayloadAction<Returned, string, {
+    args: ActionParams;
+    requestId: string;
+}, never>> & {
+    abort: (reason?: string) => void;
+}) & {
     pending: import("./createAction").ActionCreatorWithPreparedPayload<[string, ActionParams], undefined, string, never, {
         args: ActionParams;
         requestId: string;
     }>;
-    rejected: import("./createAction").ActionCreatorWithPreparedPayload<[Error, string, ActionParams], undefined, string, Error, {
+    rejected: import("./createAction").ActionCreatorWithPreparedPayload<[Error, string, ActionParams], undefined, string, any, {
         args: ActionParams;
         requestId: string;
-    }>;
-    fulfilled: import("./createAction").ActionCreatorWithPreparedPayload<[Returned, string, ActionParams], Returned, string, never, {
+    } | {
+        aborted: boolean;
+        abortReason: string;
         args: ActionParams;
         requestId: string;
     }>;
-    unwrapResult: (returned: import("./createAction").PayloadAction<Returned, string, {
-        args: ActionParams;
-        requestId: string;
-    }, never> | import("./createAction").PayloadAction<undefined, string, {
+    fulfilled: import("./createAction").ActionCreatorWithPreparedPayload<[Returned, string, ActionParams], Returned, string, never, {
         args: ActionParams;
         requestId: string;
-    }, Error>) => Returned;
+    }>;
 };
 
 // @alpha (undocumented)
@@ -273,6 +278,13 @@ export type SliceCaseReducers<State> = {
 
 export { ThunkAction }
 
+// @alpha (undocumented)
+export function unwrapResult<T>(returned: {
+    error: any;
+} | {
+    payload: NonNullable<T>;
+}): NonNullable<T>;
+
 // @alpha (undocumented)
 export type Update<T> = UpdateStr<T> | UpdateNum<T>;
 
diff --git a/src/createAsyncThunk.ts b/src/createAsyncThunk.ts
index 1e0c2160..d601e591 100644
--- a/src/createAsyncThunk.ts
+++ b/src/createAsyncThunk.ts
@@ -2,13 +2,12 @@ import { Dispatch } from 'redux'
 import nanoid from 'nanoid'
 import { createAction } from './createAction'
 
-type Await<P> = P extends PromiseLike<infer T> ? T : P
-
 type AsyncThunksArgs<S, E, D extends Dispatch = Dispatch> = {
   dispatch: D
   getState: S
   extra: E
   requestId: string
+  signal: AbortSignal
 }
 
 interface SimpleError {
@@ -89,61 +88,92 @@ export function createAsyncThunk<
     (error: Error, requestId: string, args: ActionParams) => {
       return {
         payload: undefined,
-        error,
-        meta: { args, requestId }
+        error: miniSerializeError(error),
+        meta: {
+          args,
+          requestId,
+          ...(error &&
+            error.name === 'AbortError' && {
+              aborted: true,
+              abortReason: error.message
+            })
+        }
       }
     }
   )
 
   function actionCreator(args: ActionParams) {
-    return async (
+    return (
       dispatch: TA['dispatch'],
       getState: TA['getState'],
       extra: TA['extra']
     ) => {
       const requestId = nanoid()
+      const abortController = new AbortController()
+      let abortAction: ReturnType<typeof rejected> | undefined
 
-      let finalAction: ReturnType<typeof fulfilled | typeof rejected>
-      try {
-        dispatch(pending(requestId, args))
-
-        finalAction = fulfilled(
-          await payloadCreator(args, {
-            dispatch,
-            getState,
-            extra,
-            requestId
-          } as TA),
+      function abort(reason: string = 'Aborted.') {
+        abortController.abort()
+        abortAction = rejected(
+          { name: 'AbortError', message: reason },
           requestId,
           args
         )
-      } catch (err) {
-        const serializedError = miniSerializeError(err)
-        finalAction = rejected(serializedError, requestId, args)
+        dispatch(abortAction)
       }
 
-      // We dispatch "success" _after_ the catch, to avoid having any errors
-      // here get swallowed by the try/catch block,
-      // 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)
-      return finalAction
-    }
-  }
-
-  function unwrapResult(
-    returned: Await<ReturnType<ReturnType<typeof actionCreator>>>
-  ) {
-    if (rejected.match(returned)) {
-      throw returned.error
+      const promise = (async function() {
+        let finalAction: ReturnType<typeof fulfilled | typeof rejected>
+        try {
+          dispatch(pending(requestId, args))
+
+          finalAction = fulfilled(
+            await payloadCreator(args, {
+              dispatch,
+              getState,
+              extra,
+              requestId,
+              signal: abortController.signal
+            } as TA),
+            requestId,
+            args
+          )
+        } catch (err) {
+          if (err && err.name === 'AbortError' && abortAction) {
+            // abortAction has already been dispatched, no further action should be dispatched
+            // by this thunk.
+            // return a copy of the dispatched abortAction, but attach the AbortError to it.
+            return { ...abortAction, error: miniSerializeError(err) }
+          }
+          finalAction = rejected(err, requestId, args)
+        }
+
+        // We dispatch "success" _after_ the catch, to avoid having any errors
+        // here get swallowed by the try/catch block,
+        // 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)
+        return finalAction
+      })()
+      return Object.assign(promise, { abort })
     }
-    return returned.payload
   }
 
   return Object.assign(actionCreator, {
     pending,
     rejected,
-    fulfilled,
-    unwrapResult
+    fulfilled
   })
 }
+
+/**
+ * @alpha
+ */
+export function unwrapResult<T>(
+  returned: { error: any } | { payload: NonNullable<T> }
+): NonNullable<T> {
+  if ('error' in returned) {
+    throw returned.error
+  }
+  return returned.payload
+}
diff --git a/src/index.ts b/src/index.ts
index 3897e2f2..86b9b106 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -73,4 +73,4 @@ export {
   Comparer
 } from './entities/models'
 
-export { createAsyncThunk } from './createAsyncThunk'
+export { createAsyncThunk, unwrapResult } from './createAsyncThunk'


Test patch:
diff --git a/src/createAsyncThunk.test.ts b/src/createAsyncThunk.test.ts
index 8a59fe0d..8c9b3b70 100644
--- a/src/createAsyncThunk.test.ts
+++ b/src/createAsyncThunk.test.ts
@@ -1,5 +1,10 @@
-import { createAsyncThunk, miniSerializeError } from './createAsyncThunk'
+import {
+  createAsyncThunk,
+  miniSerializeError,
+  unwrapResult
+} from './createAsyncThunk'
 import { configureStore } from './configureStore'
+import { AnyAction } from 'redux'
 
 describe('createAsyncThunk', () => {
   it('creates the action types', () => {
@@ -104,3 +109,85 @@ describe('createAsyncThunk', () => {
     expect(errorAction.meta.args).toBe(args)
   })
 })
+
+describe('createAsyncThunk with abortController', () => {
+  const asyncThunk = createAsyncThunk('test', function abortablePayloadCreator(
+    _: any,
+    { signal }
+  ) {
+    return new Promise((resolve, reject) => {
+      if (signal.aborted) {
+        reject(
+          new DOMException(
+            'This should never be reached as it should already be handled.',
+            'AbortError'
+          )
+        )
+      }
+      signal.addEventListener('abort', () => {
+        reject(new DOMException('Was aborted while running', 'AbortError'))
+      })
+      setTimeout(resolve, 100)
+    })
+  })
+
+  let store = configureStore({
+    reducer(store: AnyAction[] = []) {
+      return store
+    }
+  })
+
+  beforeEach(() => {
+    store = configureStore({
+      reducer(store: AnyAction[] = [], action) {
+        return [...store, action]
+      }
+    })
+  })
+
+  test('normal usage', async () => {
+    await store.dispatch(asyncThunk({}))
+    expect(store.getState()).toEqual([
+      expect.any(Object),
+      expect.objectContaining({ type: 'test/pending' }),
+      expect.objectContaining({ type: 'test/fulfilled' })
+    ])
+  })
+
+  test('abort after dispatch', async () => {
+    const promise = store.dispatch(asyncThunk({}))
+    promise.abort('AbortReason')
+    const result = await promise
+    const expectedAbortedAction = {
+      type: 'test/rejected',
+      error: {
+        message: 'AbortReason',
+        name: 'AbortError'
+      },
+      meta: { aborted: true, abortReason: 'AbortReason' }
+    }
+    // abortedAction with reason is dispatched after test/pending is dispatched
+    expect(store.getState()).toMatchObject([
+      {},
+      { type: 'test/pending' },
+      expectedAbortedAction
+    ])
+
+    // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
+    expect(result).toMatchObject({
+      ...expectedAbortedAction,
+      error: {
+        message: 'Was aborted while running',
+        name: 'AbortError'
+      }
+    })
+
+    // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
+    expect(() => unwrapResult(result)).toThrowError(
+      expect.objectContaining({
+        message: 'Was aborted while running',
+        name: 'AbortError'
+      })
+    )
+  })
+})
diff --git a/type-tests/files/createAsyncThunk.typetest.ts b/type-tests/files/createAsyncThunk.typetest.ts
index 04a0bf11..8027e2f1 100644
--- a/type-tests/files/createAsyncThunk.typetest.ts
+++ b/type-tests/files/createAsyncThunk.typetest.ts
@@ -1,6 +1,7 @@
 import { createAsyncThunk, Dispatch, createReducer, AnyAction } from 'src'
 import { ThunkDispatch } from 'redux-thunk'
 import { promises } from 'fs'
+import { unwrapResult } from 'src/createAsyncThunk'
 
 function expectType<T>(t: T) {
   return t
@@ -44,7 +45,7 @@ function fn() {}
   }
 
   promise
-    .then(async.unwrapResult)
+    .then(unwrapResult)
     .then(result => {
       expectType<number>(result)
       // typings:expect-error