Repository: reduxjs/redux-toolkit
Pull request #367 (2020-02-17 21:44:12)

createAsyncThunk improvements
The asyncThunk now exits early when the `abort()` method is called and doesn't keep waiting for the `payloadCreator` to finish.
This should be an improvement especially in cases where the `payloadCreator` did not support the signal, but something outside was awaiting the promise from `dispatch`.
Also, before this, a non-signal-aware `payloadCreator` could still finish which would have caused a dispatch of a "fulfilled" action after an "rejected" (abort) action, which was confusing.

Also I've removed some logic that made sense to me at the moment but really was just confusing and useless.
Also removed the `meta.abortReason` property as it is not possible any more to override the error in the "rejected"(aborted) return value, so that doesn't make sense any more.

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

Built with commit f6530ee571a3c4c01251ecbcce57277291a8b325

https://deploy-preview-367--redux-starter-kit-docs.netlify.com
markerikson: Given that you are much more familiar with how the abort stuff is supposed to work, could you add some kind of a writeup to the `createAsyncThunk` API docs page?  I know you've said docs writing isn't your strength, but if you can at least get in a basic description, I can tweak it if needed.
markerikson: aaaaand take a wild guess why CI is failing :)
phryneas: Okay, I've added some docs.

Honestly, I can't think of any real good examples of implementing those myself, but also it's midnight, so not the best time to be creative. Better than nothing for now I guess.

That example code *should*  work, but I haven't run it.
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/367/builds/11265) or the icon next to each commit SHA.
markerikson: Cool.  I'll merge it in to the feature branch, clean up some of the wording, and put out yet another alpha.

Patch:
diff --git a/docs/api/createAsyncThunk.md b/docs/api/createAsyncThunk.md
index c6bbf19d..726d66f9 100644
--- a/docs/api/createAsyncThunk.md
+++ b/docs/api/createAsyncThunk.md
@@ -133,7 +133,6 @@ interface RejectedAction<ThunkArg> {
     requestId: string
     arg: ThunkArg
     aborted: boolean
-    abortReason?: string
   }
 }
 
@@ -278,3 +277,83 @@ const UsersComponent = () => {
   // render UI here
 }
 ```
+
+## Cancellation
+
+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:
+
+```ts
+function MyComponent(props: { userId: string }) {
+  React.useEffect(() => {
+    const promise = dispatch(fetchUserById(props.userId))
+    return () => {
+      promise.abort()
+    }
+  }, [props.userId])
+}
+```
+
+After a thunk has been cancelled this way, it will dispatch (and return) a `thunkName/rejected` action with an `AbortError` on the `error` property. The thunk will not dispatch any further actions.
+
+Additionally, your `payloadCreator` can use the `AbortSignal` it is passed via `thunkApi.signal` to actually cancel a costly asynchronous action.
+
+The `fetch` api of modern browsers aleady comes with support for an `AbortSignal`:
+
+```ts
+const fetchUserById = createAsyncThunk(
+  'users/fetchById',
+  async (userId, thunkAPI) => {
+    const response = await fetch(`https://reqres.in/api/users/${userId}`, {
+      signal: thunkAPI.signal
+    })
+    return await response.json()
+  }
+)
+```
+
+But of course, you can also use it manually.
+
+### using `signal.aborted`
+
+You can use the `signal.aborted` property to regularly check if the thunk has been aborted and in that case stop costly long-running work:
+
+```ts
+const readStream = createAsyncThunk('readStream', async (stream: ReadableStream, {signal}) => {
+  const reader = stream.getReader();
+
+  let done = false;
+  let result = "";
+
+  while (!done) {
+    if (signal.aborted) {
+      throw new Error("stop the work, this has been aborted!");
+    }
+    const read = await reader.read();
+    result += read.value;
+    done = read.done;
+  }
+  return result;
+}
+```
+
+### using `signal.addEventListener('abort', () => ...)`
+
+```ts
+const readStream = createAsyncThunk(
+  'readStream',
+  (arg, { signal }) =>
+    new Promise((resolve, reject) => {
+      signal.addEventListener('abort', () => {
+        reject(new DOMException('Was aborted while running', 'AbortError'))
+      })
+
+      startActionA(arg)
+        .then(startActionB)
+        .then(startActionC)
+        .then(startActionD)
+        .then(resolve)
+    })
+)
+```
diff --git a/etc/redux-toolkit.api.md b/etc/redux-toolkit.api.md
index 52c3a4e9..33eeb639 100644
--- a/etc/redux-toolkit.api.md
+++ b/etc/redux-toolkit.api.md
@@ -103,16 +103,15 @@ 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<Returned, ThunkArg = void, ThunkApiConfig extends AsyncThunkConfig = {}>(type: string, payloadCreator: (arg: ThunkArg, thunkAPI: GetThunkAPI<ThunkApiConfig>) => Promise<Returned> | Returned): ((arg: ThunkArg) => (dispatch: GetDispatch<ThunkApiConfig>, getState: () => GetState<ThunkApiConfig>, extra: GetExtra<ThunkApiConfig>) => Promise<PayloadAction<undefined, string, {
+export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig extends AsyncThunkConfig = {}>(type: string, payloadCreator: (arg: ThunkArg, thunkAPI: GetThunkAPI<ThunkApiConfig>) => Promise<Returned> | Returned): ((arg: ThunkArg) => (dispatch: GetDispatch<ThunkApiConfig>, getState: () => GetState<ThunkApiConfig>, extra: GetExtra<ThunkApiConfig>) => Promise<PayloadAction<Returned, string, {
     arg: ThunkArg;
     requestId: string;
-    aborted: boolean;
-    abortReason: string | undefined;
-}, any> | PayloadAction<Returned, string, {
+}, never> | PayloadAction<undefined, string, {
     arg: ThunkArg;
     requestId: string;
-}, never>> & {
-    abort: (reason?: string) => void;
+    aborted: boolean;
+}, any>> & {
+    abort: (reason?: string | undefined) => void;
 }) & {
     pending: ActionCreatorWithPreparedPayload<[string, ThunkArg], undefined, string, never, {
         arg: ThunkArg;
@@ -122,7 +121,6 @@ export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig exten
         arg: ThunkArg;
         requestId: string;
         aborted: boolean;
-        abortReason: string | undefined;
     }>;
     fulfilled: ActionCreatorWithPreparedPayload<[Returned, string, ThunkArg], Returned, string, never, {
         arg: ThunkArg;
diff --git a/src/createAsyncThunk.ts b/src/createAsyncThunk.ts
index bdb3da93..07b77903 100644
--- a/src/createAsyncThunk.ts
+++ b/src/createAsyncThunk.ts
@@ -133,8 +133,7 @@ export function createAsyncThunk<
         meta: {
           arg,
           requestId,
-          aborted,
-          abortReason: aborted ? error.message : undefined
+          aborted
         }
       }
     }
@@ -147,49 +146,45 @@ export function createAsyncThunk<
       extra: GetExtra<ThunkApiConfig>
     ) => {
       const requestId = nanoid()
+
       const abortController = new AbortController()
-      let abortAction: ReturnType<typeof rejected> | undefined
+      let abortReason: string | undefined
 
-      function abort(reason: string = 'Aborted.') {
-        abortController.abort()
-        abortAction = rejected(
-          { name: 'AbortError', message: reason },
-          requestId,
-          arg
+      const abortedPromise = new Promise<never>((_, reject) =>
+        abortController.signal.addEventListener('abort', () =>
+          reject({ name: 'AbortError', message: abortReason || 'Aborted.' })
         )
-        dispatch(abortAction)
+      )
+
+      function abort(reason?: string) {
+        abortReason = reason
+        abortController.abort()
       }
 
       const promise = (async function() {
         let finalAction: ReturnType<typeof fulfilled | typeof rejected>
         try {
           dispatch(pending(requestId, arg))
-
-          finalAction = fulfilled(
-            await payloadCreator(arg, {
-              dispatch,
-              getState,
-              extra,
-              requestId,
-              signal: abortController.signal
-            }),
-            requestId,
-            arg
-          )
+          finalAction = await Promise.race([
+            abortedPromise,
+            Promise.resolve(
+              payloadCreator(arg, {
+                dispatch,
+                getState,
+                extra,
+                requestId,
+                signal: abortController.signal
+              })
+            ).then(result => fulfilled(result, requestId, arg))
+          ])
         } 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, arg)
         }
-
         // We dispatch the result action _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
       })()


Test patch:
diff --git a/src/createAsyncThunk.test.ts b/src/createAsyncThunk.test.ts
index 6fc9c12a..e5ccebd5 100644
--- a/src/createAsyncThunk.test.ts
+++ b/src/createAsyncThunk.test.ts
@@ -162,7 +162,7 @@ describe('createAsyncThunk with abortController', () => {
         message: 'AbortReason',
         name: 'AbortError'
       },
-      meta: { aborted: true, abortReason: 'AbortReason' }
+      meta: { aborted: true }
     }
     // abortedAction with reason is dispatched after test/pending is dispatched
     expect(store.getState()).toMatchObject([
@@ -172,20 +172,65 @@ describe('createAsyncThunk with abortController', () => {
     ])
 
     // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
-    expect(result).toMatchObject({
-      ...expectedAbortedAction,
+    expect(result).toMatchObject(expectedAbortedAction)
+
+    // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
+    expect(() => unwrapResult(result)).toThrowError(
+      expect.objectContaining(expectedAbortedAction.error)
+    )
+  })
+
+  test('even when the payloadCreator does not directly support the signal, no further actions are dispatched', async () => {
+    const unawareAsyncThunk = createAsyncThunk('unaware', async () => {
+      await new Promise(resolve => setTimeout(resolve, 100))
+      return 'finished'
+    })
+
+    const promise = store.dispatch(unawareAsyncThunk())
+    promise.abort('AbortReason')
+    const result = await promise
+
+    const expectedAbortedAction = {
+      type: 'unaware/rejected',
       error: {
-        message: 'Was aborted while running',
+        message: 'AbortReason',
         name: 'AbortError'
       }
-    })
+    }
+
+    // abortedAction with reason is dispatched after test/pending is dispatched
+    expect(store.getState()).toEqual([
+      expect.any(Object),
+      expect.objectContaining({ type: 'unaware/pending' }),
+      expect.objectContaining(expectedAbortedAction)
+    ])
+
+    // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
+    expect(result).toMatchObject(expectedAbortedAction)
 
     // 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'
-      })
+      expect.objectContaining(expectedAbortedAction.error)
     )
   })
+
+  test('dispatch(asyncThunk) returns on abort and does not wait for the promiseProvider to finish', async () => {
+    let running = false
+    const longRunningAsyncThunk = createAsyncThunk('longRunning', async () => {
+      running = true
+      await new Promise(resolve => setTimeout(resolve, 30000))
+      running = false
+    })
+
+    const promise = store.dispatch(longRunningAsyncThunk())
+    expect(running).toBeTruthy()
+    promise.abort()
+    const result = await promise
+    expect(running).toBeTruthy()
+    expect(result).toMatchObject({
+      type: 'longRunning/rejected',
+      error: { message: 'Aborted.', name: 'AbortError' },
+      meta: { aborted: true }
+    })
+  })
 })