Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

createAsyncThunk improvements #367

Merged
merged 4 commits into from
Feb 18, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion docs/api/createAsyncThunk.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ interface RejectedAction<ThunkArg> {
requestId: string
arg: ThunkArg
aborted: boolean
abortReason?: string
}
}

Expand Down Expand Up @@ -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)
})
)
```
12 changes: 5 additions & 7 deletions etc/redux-toolkit.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
63 changes: 54 additions & 9 deletions src/createAsyncThunk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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 }
})
})
})
53 changes: 24 additions & 29 deletions src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,7 @@ export function createAsyncThunk<
meta: {
arg,
requestId,
aborted,
abortReason: aborted ? error.message : undefined
aborted
}
}
}
Expand All @@ -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
})()
Expand Down