Skip to content

Commit 8c6c792

Browse files
authored
createAsyncThunk improvements (#367)
* prevent dispatching of further actions if asyncThunk has been cancelled, even if the payloadCreator didn't react to the `abort` request * * add race between payloadCreator and abortedPromise * simplify createAsyncThunk * remove complicated logic where an AbortError thrown from the `payloadCreator` could influence the return value * api report * doc examples for cancellation
1 parent 4c7c92a commit 8c6c792

File tree

4 files changed

+163
-46
lines changed

4 files changed

+163
-46
lines changed

docs/api/createAsyncThunk.md

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ interface RejectedAction<ThunkArg> {
133133
requestId: string
134134
arg: ThunkArg
135135
aborted: boolean
136-
abortReason?: string
137136
}
138137
}
139138

@@ -278,3 +277,83 @@ const UsersComponent = () => {
278277
// render UI here
279278
}
280279
```
280+
281+
## Cancellation
282+
283+
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))`.
284+
285+
A real-life example of that would look like this:
286+
287+
```ts
288+
function MyComponent(props: { userId: string }) {
289+
React.useEffect(() => {
290+
const promise = dispatch(fetchUserById(props.userId))
291+
return () => {
292+
promise.abort()
293+
}
294+
}, [props.userId])
295+
}
296+
```
297+
298+
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.
299+
300+
Additionally, your `payloadCreator` can use the `AbortSignal` it is passed via `thunkApi.signal` to actually cancel a costly asynchronous action.
301+
302+
The `fetch` api of modern browsers aleady comes with support for an `AbortSignal`:
303+
304+
```ts
305+
const fetchUserById = createAsyncThunk(
306+
'users/fetchById',
307+
async (userId, thunkAPI) => {
308+
const response = await fetch(`https://reqres.in/api/users/${userId}`, {
309+
signal: thunkAPI.signal
310+
})
311+
return await response.json()
312+
}
313+
)
314+
```
315+
316+
But of course, you can also use it manually.
317+
318+
### using `signal.aborted`
319+
320+
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:
321+
322+
```ts
323+
const readStream = createAsyncThunk('readStream', async (stream: ReadableStream, {signal}) => {
324+
const reader = stream.getReader();
325+
326+
let done = false;
327+
let result = "";
328+
329+
while (!done) {
330+
if (signal.aborted) {
331+
throw new Error("stop the work, this has been aborted!");
332+
}
333+
const read = await reader.read();
334+
result += read.value;
335+
done = read.done;
336+
}
337+
return result;
338+
}
339+
```
340+
341+
### using `signal.addEventListener('abort', () => ...)`
342+
343+
```ts
344+
const readStream = createAsyncThunk(
345+
'readStream',
346+
(arg, { signal }) =>
347+
new Promise((resolve, reject) => {
348+
signal.addEventListener('abort', () => {
349+
reject(new DOMException('Was aborted while running', 'AbortError'))
350+
})
351+
352+
startActionA(arg)
353+
.then(startActionB)
354+
.then(startActionC)
355+
.then(startActionD)
356+
.then(resolve)
357+
})
358+
)
359+
```

etc/redux-toolkit.api.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,15 @@ export function createAction<P = void, T extends string = string>(type: T): Payl
103103
export function createAction<PA extends PrepareAction<any>, T extends string = string>(type: T, prepareAction: PA): PayloadActionCreator<ReturnType<PA>['payload'], T, PA>;
104104

105105
// @alpha (undocumented)
106-
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, {
106+
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, {
107107
arg: ThunkArg;
108108
requestId: string;
109-
aborted: boolean;
110-
abortReason: string | undefined;
111-
}, any> | PayloadAction<Returned, string, {
109+
}, never> | PayloadAction<undefined, string, {
112110
arg: ThunkArg;
113111
requestId: string;
114-
}, never>> & {
115-
abort: (reason?: string) => void;
112+
aborted: boolean;
113+
}, any>> & {
114+
abort: (reason?: string | undefined) => void;
116115
}) & {
117116
pending: ActionCreatorWithPreparedPayload<[string, ThunkArg], undefined, string, never, {
118117
arg: ThunkArg;
@@ -122,7 +121,6 @@ export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig exten
122121
arg: ThunkArg;
123122
requestId: string;
124123
aborted: boolean;
125-
abortReason: string | undefined;
126124
}>;
127125
fulfilled: ActionCreatorWithPreparedPayload<[Returned, string, ThunkArg], Returned, string, never, {
128126
arg: ThunkArg;

src/createAsyncThunk.test.ts

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ describe('createAsyncThunk with abortController', () => {
162162
message: 'AbortReason',
163163
name: 'AbortError'
164164
},
165-
meta: { aborted: true, abortReason: 'AbortReason' }
165+
meta: { aborted: true }
166166
}
167167
// abortedAction with reason is dispatched after test/pending is dispatched
168168
expect(store.getState()).toMatchObject([
@@ -172,20 +172,65 @@ describe('createAsyncThunk with abortController', () => {
172172
])
173173

174174
// same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
175-
expect(result).toMatchObject({
176-
...expectedAbortedAction,
175+
expect(result).toMatchObject(expectedAbortedAction)
176+
177+
// calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
178+
expect(() => unwrapResult(result)).toThrowError(
179+
expect.objectContaining(expectedAbortedAction.error)
180+
)
181+
})
182+
183+
test('even when the payloadCreator does not directly support the signal, no further actions are dispatched', async () => {
184+
const unawareAsyncThunk = createAsyncThunk('unaware', async () => {
185+
await new Promise(resolve => setTimeout(resolve, 100))
186+
return 'finished'
187+
})
188+
189+
const promise = store.dispatch(unawareAsyncThunk())
190+
promise.abort('AbortReason')
191+
const result = await promise
192+
193+
const expectedAbortedAction = {
194+
type: 'unaware/rejected',
177195
error: {
178-
message: 'Was aborted while running',
196+
message: 'AbortReason',
179197
name: 'AbortError'
180198
}
181-
})
199+
}
200+
201+
// abortedAction with reason is dispatched after test/pending is dispatched
202+
expect(store.getState()).toEqual([
203+
expect.any(Object),
204+
expect.objectContaining({ type: 'unaware/pending' }),
205+
expect.objectContaining(expectedAbortedAction)
206+
])
207+
208+
// same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
209+
expect(result).toMatchObject(expectedAbortedAction)
182210

183211
// calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
184212
expect(() => unwrapResult(result)).toThrowError(
185-
expect.objectContaining({
186-
message: 'Was aborted while running',
187-
name: 'AbortError'
188-
})
213+
expect.objectContaining(expectedAbortedAction.error)
189214
)
190215
})
216+
217+
test('dispatch(asyncThunk) returns on abort and does not wait for the promiseProvider to finish', async () => {
218+
let running = false
219+
const longRunningAsyncThunk = createAsyncThunk('longRunning', async () => {
220+
running = true
221+
await new Promise(resolve => setTimeout(resolve, 30000))
222+
running = false
223+
})
224+
225+
const promise = store.dispatch(longRunningAsyncThunk())
226+
expect(running).toBeTruthy()
227+
promise.abort()
228+
const result = await promise
229+
expect(running).toBeTruthy()
230+
expect(result).toMatchObject({
231+
type: 'longRunning/rejected',
232+
error: { message: 'Aborted.', name: 'AbortError' },
233+
meta: { aborted: true }
234+
})
235+
})
191236
})

src/createAsyncThunk.ts

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,7 @@ export function createAsyncThunk<
133133
meta: {
134134
arg,
135135
requestId,
136-
aborted,
137-
abortReason: aborted ? error.message : undefined
136+
aborted
138137
}
139138
}
140139
}
@@ -147,49 +146,45 @@ export function createAsyncThunk<
147146
extra: GetExtra<ThunkApiConfig>
148147
) => {
149148
const requestId = nanoid()
149+
150150
const abortController = new AbortController()
151-
let abortAction: ReturnType<typeof rejected> | undefined
151+
let abortReason: string | undefined
152152

153-
function abort(reason: string = 'Aborted.') {
154-
abortController.abort()
155-
abortAction = rejected(
156-
{ name: 'AbortError', message: reason },
157-
requestId,
158-
arg
153+
const abortedPromise = new Promise<never>((_, reject) =>
154+
abortController.signal.addEventListener('abort', () =>
155+
reject({ name: 'AbortError', message: abortReason || 'Aborted.' })
159156
)
160-
dispatch(abortAction)
157+
)
158+
159+
function abort(reason?: string) {
160+
abortReason = reason
161+
abortController.abort()
161162
}
162163

163164
const promise = (async function() {
164165
let finalAction: ReturnType<typeof fulfilled | typeof rejected>
165166
try {
166167
dispatch(pending(requestId, arg))
167-
168-
finalAction = fulfilled(
169-
await payloadCreator(arg, {
170-
dispatch,
171-
getState,
172-
extra,
173-
requestId,
174-
signal: abortController.signal
175-
}),
176-
requestId,
177-
arg
178-
)
168+
finalAction = await Promise.race([
169+
abortedPromise,
170+
Promise.resolve(
171+
payloadCreator(arg, {
172+
dispatch,
173+
getState,
174+
extra,
175+
requestId,
176+
signal: abortController.signal
177+
})
178+
).then(result => fulfilled(result, requestId, arg))
179+
])
179180
} catch (err) {
180-
if (err && err.name === 'AbortError' && abortAction) {
181-
// abortAction has already been dispatched, no further action should be dispatched
182-
// by this thunk.
183-
// return a copy of the dispatched abortAction, but attach the AbortError to it.
184-
return { ...abortAction, error: miniSerializeError(err) }
185-
}
186181
finalAction = rejected(err, requestId, arg)
187182
}
188-
189183
// We dispatch the result action _after_ the catch, to avoid having any errors
190184
// here get swallowed by the try/catch block,
191185
// per https://twitter.com/dan_abramov/status/770914221638942720
192186
// and https://redux-toolkit.js.org/tutorials/advanced-tutorial#async-error-handling-logic-in-thunks
187+
193188
dispatch(finalAction)
194189
return finalAction
195190
})()

0 commit comments

Comments
 (0)