Skip to content

Commit 3fb4526

Browse files
authored
feat(createAsyncThunk): async condition (#1496)
1 parent 38a9316 commit 3fb4526

File tree

3 files changed

+71
-36
lines changed

3 files changed

+71
-36
lines changed

docs/api/createAsyncThunk.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ The logic in the `payloadCreator` function may use any of these values as needed
9696

9797
An object with the following optional fields:
9898

99-
- `condition(arg, { getState, extra } ): boolean`: 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.
99+
- `condition(arg, { getState, extra } ): boolean | Promise<boolean>`: 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.
100100
- `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`.
101101
- `idGenerator(): string`: a function to use when generating the `requestId` for the request sequence. Defaults to use [nanoid](./otherExports.mdx/#nanoid).
102102
- `serializeError(error: unknown) => any` to replace the internal `miniSerializeError` method with your own serialization logic.
@@ -357,7 +357,7 @@ const updateUser = createAsyncThunk(
357357

358358
### Canceling Before Execution
359359

360-
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:
360+
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 or a promise that should resolve to `false`. If a promise is returned, the thunk waits for it to get fulfilled before dispatching the `pending` action, otherwise it proceeds with dispatching synchronously.
361361

362362
```js
363363
const fetchUserById = createAsyncThunk(

packages/toolkit/src/createAsyncThunk.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ export type AsyncThunkOptions<
286286
condition?(
287287
arg: ThunkArg,
288288
api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
289-
): boolean | undefined
289+
): MaybePromise<boolean | undefined>
290290
/**
291291
* If `condition` returns `false`, the asyncThunk will be skipped.
292292
* This option allows you to control whether a `rejected` action with `meta.condition == false`
@@ -553,11 +553,11 @@ If you want to use the AbortController to react to \`abort\` events, please cons
553553
const promise = (async function () {
554554
let finalAction: ReturnType<typeof fulfilled | typeof rejected>
555555
try {
556-
if (
557-
options &&
558-
options.condition &&
559-
options.condition(arg, { getState, extra }) === false
560-
) {
556+
let conditionResult = options?.condition?.(arg, { getState, extra })
557+
if (isThenable(conditionResult)) {
558+
conditionResult = await conditionResult
559+
}
560+
if (conditionResult === false) {
561561
// eslint-disable-next-line no-throw-literal
562562
throw {
563563
name: 'ConditionError',
@@ -678,3 +678,11 @@ export function unwrapResult<R extends UnwrappableAction>(
678678
type WithStrictNullChecks<True, False> = undefined extends boolean
679679
? False
680680
: True
681+
682+
function isThenable(value: any): value is PromiseLike<any> {
683+
return (
684+
value !== null &&
685+
typeof value === 'object' &&
686+
typeof value.then === 'function'
687+
)
688+
}

packages/toolkit/src/tests/createAsyncThunk.test.ts

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,32 @@ describe('conditional skipping of asyncThunks', () => {
595595
)
596596
})
597597

598+
test('pending is dispatched synchronously if condition is synchronous', async () => {
599+
const condition = () => true
600+
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
601+
const thunkCallPromise = asyncThunk(arg)(dispatch, getState, extra)
602+
expect(dispatch).toHaveBeenCalledTimes(1)
603+
await thunkCallPromise
604+
expect(dispatch).toHaveBeenCalledTimes(2)
605+
})
606+
607+
test('async condition', async () => {
608+
const condition = () => Promise.resolve(false)
609+
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
610+
await asyncThunk(arg)(dispatch, getState, extra)
611+
expect(dispatch).toHaveBeenCalledTimes(0)
612+
})
613+
614+
test('async condition with rejected promise', async () => {
615+
const condition = () => Promise.reject()
616+
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
617+
await asyncThunk(arg)(dispatch, getState, extra)
618+
expect(dispatch).toHaveBeenCalledTimes(1)
619+
expect(dispatch).toHaveBeenLastCalledWith(
620+
expect.objectContaining({ type: 'test/rejected' })
621+
)
622+
})
623+
598624
test('rejected action is not dispatched by default', async () => {
599625
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
600626
await asyncThunk(arg)(dispatch, getState, extra)
@@ -644,38 +670,39 @@ describe('conditional skipping of asyncThunks', () => {
644670
})
645671
)
646672
})
673+
})
647674

648-
test('serializeError implementation', async () => {
649-
function serializeError() {
650-
return 'serialized!'
651-
}
652-
const errorObject = 'something else!'
675+
test('serializeError implementation', async () => {
676+
function serializeError() {
677+
return 'serialized!'
678+
}
679+
const errorObject = 'something else!'
653680

654-
const store = configureStore({
655-
reducer: (state = [], action) => [...state, action],
656-
})
681+
const store = configureStore({
682+
reducer: (state = [], action) => [...state, action],
683+
})
657684

658-
const asyncThunk = createAsyncThunk<
659-
unknown,
660-
void,
661-
{ serializedErrorType: string }
662-
>('test', () => Promise.reject(errorObject), { serializeError })
663-
const rejected = await store.dispatch(asyncThunk())
664-
if (!asyncThunk.rejected.match(rejected)) {
665-
throw new Error()
666-
}
685+
const asyncThunk = createAsyncThunk<
686+
unknown,
687+
void,
688+
{ serializedErrorType: string }
689+
>('test', () => Promise.reject(errorObject), { serializeError })
690+
const rejected = await store.dispatch(asyncThunk())
691+
if (!asyncThunk.rejected.match(rejected)) {
692+
throw new Error()
693+
}
667694

668-
const expectation = {
669-
type: 'test/rejected',
670-
payload: undefined,
671-
error: 'serialized!',
672-
meta: expect.any(Object),
673-
}
674-
expect(rejected).toEqual(expectation)
675-
expect(store.getState()[2]).toEqual(expectation)
676-
expect(rejected.error).not.toEqual(miniSerializeError(errorObject))
677-
})
695+
const expectation = {
696+
type: 'test/rejected',
697+
payload: undefined,
698+
error: 'serialized!',
699+
meta: expect.any(Object),
700+
}
701+
expect(rejected).toEqual(expectation)
702+
expect(store.getState()[2]).toEqual(expectation)
703+
expect(rejected.error).not.toEqual(miniSerializeError(errorObject))
678704
})
705+
679706
describe('unwrapResult', () => {
680707
const getState = jest.fn(() => ({}))
681708
const dispatch = jest.fn((x: any) => x)
@@ -790,7 +817,7 @@ describe('idGenerator option', () => {
790817
})
791818
})
792819

793-
test('`condition` will see state changes from a synchonously invoked asyncThunk', () => {
820+
test('`condition` will see state changes from a synchronously invoked asyncThunk', () => {
794821
type State = ReturnType<typeof store.getState>
795822
const onStart = jest.fn()
796823
const asyncThunk = createAsyncThunk<

0 commit comments

Comments
 (0)