diff --git a/docs/api/createAsyncThunk.md b/docs/api/createAsyncThunk.md index 18d568c409..3110795cae 100644 --- a/docs/api/createAsyncThunk.md +++ b/docs/api/createAsyncThunk.md @@ -64,7 +64,7 @@ For example, a `type` argument of `'users/requestStatus'` will generate these ac ### `payloadCreator` -A callback function that should return a promise containing the result of some asynchronous logic. It may also return a value synchronously. If there is an error, it should return a rejected promise containing either an `Error` instance or a plain value such as a descriptive error message. +A callback function that should return a promise containing the result of some asynchronous logic. It may also return a value synchronously. If there is an error, it should either return a rejected promise containing an `Error` instance or a plain value such as a descriptive error message or otherwise a resolved promise with a `RejectWithValue` argument as returned by the `thunkApi.rejectWithValue` function. The `payloadCreator` function can contain whatever logic you need to calculate an appropriate result. This could include a standard AJAX data fetch request, multiple AJAX calls with the results combined into a final value, interactions with React Native `AsyncStorage`, and so on. @@ -77,6 +77,7 @@ The `payloadCreator` function will be called with two arguments: - `extra`: the "extra argument" given to the thunk middleware on setup, if available - `requestId`: a unique string ID value that was automatically generated to identify this request sequence - `signal`: an [`AbortController.signal` object](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal) that may be used to see if another part of the app logic has marked this request as needing cancelation. + - `rejectWithValue`: rejectWithValue is a utility function that you can `return` in your action creator to return a rejected response with a defined payload. It will pass whatever value you give it and return it in the payload of the rejected action. The logic in the `payloadCreator` function may use any of these values as needed to calculate the result. @@ -90,7 +91,8 @@ When dispatched, the thunk will: - call the `payloadCreator` callback and wait for the returned promise to settle - when the promise settles: - if the promise resolved successfully, dispatch the `fulfilled` action with the promise value as `action.payload` - - if the promise failed, dispatch the `rejected` action with a serialized version of the error value as `action.error` + - if the promise resolved with a `rejectWithValue(value)` return value, dispatch the `rejected` action with the value passed into `action.payload` and 'Rejected' as `action.error.message` + - if the promise failed and was not handled with `rejectWithValue`, dispatch the `rejected` action with a serialized version of the error value as `action.error` - Return a fulfilled promise containing the final dispatched action (either the `fulfilled` or `rejected` action object) ## Promise Lifecycle Actions @@ -99,7 +101,7 @@ When dispatched, the thunk will: The action creators will have these signatures: -```ts +```typescript interface SerializedError { name?: string message?: string @@ -136,6 +138,17 @@ interface RejectedAction { } } +interface RejectedWithValueAction { + type: string + payload: RejectedValue + error: { message: 'Rejected' } + meta: { + requestId: string + arg: ThunkArg + aborted: boolean + } +} + type Pending = ( requestId: string, arg: ThunkArg @@ -151,6 +164,11 @@ type Rejected = ( requestId: string, arg: ThunkArg ) => RejectedAction + +type RejectedWithValue = ( + requestId: string, + arg: ThunkArg +) => RejectedWithValueAction ``` To handle these actions in your reducers, reference the action creators in `createReducer` or `createSlice` using either the object key notation or the "builder callback" notation: @@ -299,7 +317,7 @@ const fetchUserById = createAsyncThunk( ## Examples -Requesting a user by ID, with loading state, and only one request at a time: +- Requesting a user by ID, with loading state, and only one request at a time: ```js import { createAsyncThunk, createSlice, unwrapResult } from '@reduxjs/toolkit' @@ -352,7 +370,7 @@ const UsersComponent = () => { const fetchOneUser = async userId => { try { - const resultAction = dispatch(fetchUserById(userId)) + const resultAction = await dispatch(fetchUserById(userId)) const user = unwrapResult(resultAction) showToast('success', `Fetched ${user.name}`) } catch (err) { @@ -363,3 +381,181 @@ const UsersComponent = () => { // render UI here } ``` + +- Using rejectWithValue to access a custom rejected payload in a component + +```js +import { createAsyncThunk, createSlice, unwrapResult } from '@reduxjs/toolkit' +import { userAPI } from './userAPI' + +const updateUser = createAsyncThunk( + 'users/update', + async (userData, { rejectWithValue }) => { + const { id, ...fields } = userData + try { + const response = await userAPI.updateById(id, fields) + return response.data.user + } catch (err) { + // Note: this is an example assuming the usage of axios. Other fetching libraries would likely have different implementations + if (!err.response) { + throw err + } + + return rejectWithValue(err.response.data) + } + } +) + +const usersSlice = createSlice({ + name: 'users', + initialState: { + entities: {}, + error: null + }, + reducers: {}, + extraReducers: { + [updateUser.fullfilled]: (state, action) => { + const user = action.payload + state.entities[user.id] = user + }, + [updateUser.rejected]: (state, action) => { + if (action.payload) { + // If a rejected action has a payload, it means that it was returned with rejectWithValue + state.error = action.payload.errorMessage + } else { + state.error = action.error + } + } + } +}) + +const UsersComponent = () => { + const { users, loading, error } = useSelector(state => state.users) + const dispatch = useDispatch() + + // This is an example of an onSubmit handler using Formik meant to demonstrate accessing the payload of the rejected action + const handleUpdateUser = async (values, formikHelpers) => { + const resultAction = await dispatch(updateUser(values)) + if (updateUser.fulfilled.match(resultAction)) { + const user = unwrapResult(resultAction) + showToast('success', `Updated ${user.name}`) + } else { + if (resultAction.payload) { + // This is assuming the api returned a 400 error with a body of { errorMessage: 'Validation errors', field_errors: { field_name: 'Should be a string' } } + formikHelpers.setErrors(resultAction.payload.field_errors) + } else { + showToast('error', `Update failed: ${resultAction.error}`) + } + } + } + + // render UI here +} +``` + +- TypeScript: Using rejectWithValue to access a custom rejected payload in a component + _Note: this is a contrived example assuming our userAPI only ever throws validation-specific errors_ + +```typescript +import { createAsyncThunk, createSlice, unwrapResult } from '@reduxjs/toolkit' +import { userAPI } from './userAPI' +import { AppDispatch, RootState } from '../store' +import { FormikHelpers } from 'formik' + +// Sample types that will be used +interface User { + first_name: string + last_name: string + email: string +} + +interface ValidationErrors { + errorMessage: string + field_errors: Record +} + +interface UpdateUserResponse { + user: User + success: boolean +} + +const updateUser = createAsyncThunk< + User, + Partial, + { + rejectValue: ValidationErrors + } +>('users/update', async (userData, { rejectWithValue }) => { + try { + const { id, ...fields } = userData + const response = await userAPI.updateById(id, fields) + return response.data.user + } catch (err) { + let error: AxiosError = err // cast the error for access + if (!error.response) { + throw err + } + // We got validation errors, let's return those so we can reference in our component and set form errors + return rejectWithValue(error.response.data) + } +}) + +interface UsersState { + error: string | null + entities: Record +} + +const initialState: UsersState = { + entities: {}, + error: null +} + +const usersSlice = createSlice({ + name: 'users', + initialState, + reducers: {}, + extraReducers: builder => { + // The `builder` callback form is used here because it provides correctly typed reducers from the action creators + builder.addCase(updateUser.fulfilled, (state, { payload }) => { + state.entities[payload.id] = payload + }) + builder.addCase(updateUser.rejected, (state, action) => { + if (action.payload) { + // Being that we passed in ValidationErrors to rejectType in `createAsyncThunk`, the payload will be available here. + state.error = action.payload.errorMessage + } else { + state.error = action.error + } + }) + } +}) + +const UsersComponent = () => { + const { users, loading, error } = useSelector( + (state: RootState) => state.users + ) + const dispatch: AppDispatch = useDispatch() + + // This is an example of an onSubmit handler using Formik meant to demonstrate accessing the payload of the rejected action + const handleUpdateUser = async ( + values: FormValues, + formikHelpers: FormikHelpers + ) => { + const resultAction = await dispatch(updateUser(values)) + if (updateUser.fulfilled.match(resultAction)) { + // user will have a type signature of User as we passed that as the Returned parameter in createAsyncThunk + const user = unwrapResult(resultAction) + showToast('success', `Updated ${user.name}`) + } else { + if (resultAction.payload) { + // Being that we passed in ValidationErrors to rejectType in `createAsyncThunk`, those types will be available here. + formikHelpers.setErrors(resultAction.payload.field_errors) + } else { + showToast('error', `Update failed: ${resultAction.error}`) + } + } + } + + // render UI here +} +``` diff --git a/docs/usage/usage-with-typescript.md b/docs/usage/usage-with-typescript.md index f084fa2db4..0ae3a30912 100644 --- a/docs/usage/usage-with-typescript.md +++ b/docs/usage/usage-with-typescript.md @@ -420,14 +420,14 @@ const fetchUserById = createAsyncThunk( const lastReturnedAction = await store.dispatch(fetchUserById(3)) ``` -The second argument to the `payloadCreator`, known as `thunkApi`, is an object containing references to the `dispatch`, `getState`, and `extra` arguments from the thunk middleware. If you want to use these from within the `payloadCreator`, you will need to define some generic arguments, as the types for these arguments cannot be inferred. Also, as TS cannot mix explicit and inferred generic parameters, from this point on you'll have to define the `Returned` and `ThunkArg` generic parameter as well. +The second argument to the `payloadCreator`, known as `thunkApi`, is an object containing references to the `dispatch`, `getState`, and `extra` arguments from the thunk middleware as well as a utility function called `rejectWithValue`. If you want to use these from within the `payloadCreator`, you will need to define some generic arguments, as the types for these arguments cannot be inferred. Also, as TS cannot mix explicit and inferred generic parameters, from this point on you'll have to define the `Returned` and `ThunkArg` generic parameter as well. -To define the types for these arguments, pass an object as the third generic argument, with type declarations for some or all of these fields: `{dispatch?, state?, extra?}`. +To define the types for these arguments, pass an object as the third generic argument, with type declarations for some or all of these fields: `{dispatch?, state?, extra?, rejectValue?}`. -```ts {2-12} +```ts const fetchUserById = createAsyncThunk< // Return type of the payload creator - Promise, + MyData, // First argument to the payload creator number, { @@ -447,7 +447,98 @@ const fetchUserById = createAsyncThunk< }) ``` -While this notation for `state`, `dispatch` and `extra` might seem uncommon at first, it allows you to provide only the types for these you actually need - so for example, if you are not accessing `getState` within your `payloadCreator`, there is no need to provide a type for `state`. +If you are performing a request that you know will typically either be a success or have an expected error format, you can pass in a type to `rejectValue` and `return rejectWithValue(knownPayload)` in the action creator. This allows you to reference the error payload in the reducer as well as in a component after dispatching the `createAsyncThunk` action. + +```ts +interface MyKnownError { + errorMessage: string + // ... +} +interface UserAttributes { + id: string + first_name: string + last_name: string + email: string +} + +const updateUser = createAsyncThunk< + // Return type of the payload creator + MyData, + // First argument to the payload creator + UserAttributes, + // Types for ThunkAPI + { + extra: { + jwt: string + } + rejectValue: MyKnownError + } +>('users/update', async (user, thunkApi) => { + const { id, ...userData } = user + const response = await fetch(`https://reqres.in/api/users/${id}`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${thunkApi.extra.jwt}` + }, + body: JSON.stringify(userData) + }) + if (response.status === 400) { + // Return the known error for future handling + return thunkApi.rejectWithValue((await response.json()) as MyKnownError) + } + return (await response.json()) as MyData +}) +``` + +While this notation for `state`, `dispatch`, `extra` and `rejectValue` might seem uncommon at first, it allows you to provide only the types for these you actually need - so for example, if you are not accessing `getState` within your `payloadCreator`, there is no need to provide a type for `state`. The same can be said about `rejectValue` - if you don't need to access any potential error payload, you can ignore it. + +In addition, you can leverage checks against `action.payload` and `match` as provided by `createAction` as a type-guard for when you want to access known properties on defined types. Example: + +- In a reducer + +```ts +const usersSlice = createSlice({ + name: 'users', + initialState: { + entities: {}, + error: null + }, + reducers: {}, + extraReducers: builder => { + builder.addCase(updateUser.fulfilled, (state, { payload }) => { + state.entities[payload.id] = payload + }) + builder.addCase(updateUser.rejected, (state, action) => { + if (action.payload) { + // Since we passed in `MyKnownError` to `rejectType` in `updateUser`, the type information will be available here. + state.error = action.payload.errorMessage + } else { + state.error = action.error + } + }) + } +}) +``` + +- In a component + +```ts +const handleUpdateUser = async userData => { + const resultAction = await dispatch(updateUser(userData)) + if (updateUser.fulfilled.match(resultAction)) { + const user = unwrapResult(resultAction) + showToast('success', `Updated ${user.name}`) + } else { + if (resultAction.payload) { + // Since we passed in `MyKnownError` to `rejectType` in `updateUser`, the type information will be available here. + // Note: this would also be a good place to do any handling that relies on the `rejectedWithValue` payload, such as setting field errors + showToast('error', `Update failed: ${resultAction.payload.errorMessage}`) + } else { + showToast('error', `Update failed: ${resultAction.error.message}`) + } + } +} +``` ## `createEntityAdapter` diff --git a/etc/redux-toolkit.api.md b/etc/redux-toolkit.api.md index a4ac2c2d10..8b19b879d3 100644 --- a/etc/redux-toolkit.api.md +++ b/etc/redux-toolkit.api.md @@ -103,21 +103,21 @@ export function createAction

(type: T): Payl export function createAction, T extends string = string>(type: T, prepareAction: PA): PayloadActionCreator['payload'], T, PA>; // @alpha (undocumented) -export function createAsyncThunk(type: string, payloadCreator: (arg: ThunkArg, thunkAPI: GetThunkAPI) => Promise | Returned): ((arg: ThunkArg) => (dispatch: GetDispatch, getState: () => GetState, extra: GetExtra) => Promise(type: string, payloadCreator: (arg: ThunkArg, thunkAPI: GetThunkAPI) => Promise>> | Returned | RejectWithValue>): ((arg: ThunkArg) => (dispatch: GetDispatch, getState: () => GetState, extra: GetExtra) => Promise | PayloadAction | PayloadAction | undefined, string, { arg: ThunkArg; requestId: string; aborted: boolean; -}, any>> & { +}, SerializedError>> & { abort: (reason?: string | undefined) => void; }) & { pending: ActionCreatorWithPreparedPayload<[string, ThunkArg], undefined, string, never, { arg: ThunkArg; requestId: string; }>; - rejected: ActionCreatorWithPreparedPayload<[Error, string, ThunkArg], undefined, string, any, { + rejected: ActionCreatorWithPreparedPayload<[Error | null, string, ThunkArg, (GetRejectValue | undefined)?], GetRejectValue | undefined, string, SerializedError, { arg: ThunkArg; requestId: string; aborted: boolean; @@ -284,11 +284,7 @@ export type SliceCaseReducers = { export { ThunkAction } // @alpha (undocumented) -export function unwrapResult(returned: { - error: any; -} | { - payload: NonNullable; -}): NonNullable; +export function unwrapResult(returned: R): PayloadForActionTypesExcludingErrorActions; // @alpha (undocumented) export type Update = { diff --git a/package-lock.json b/package-lock.json index cc2fee0450..dfcd8ca799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1688,6 +1688,15 @@ "integrity": "sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A==", "dev": true }, + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "dev": true, + "requires": { + "follow-redirects": "1.5.10" + } + }, "axobject-query": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.1.1.tgz", @@ -3618,6 +3627,32 @@ "integrity": "sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==", "dev": true }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "dev": true, + "requires": { + "debug": "=3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -3689,8 +3724,7 @@ "version": "2.1.1", "resolved": false, "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -3714,15 +3748,13 @@ "version": "1.0.0", "resolved": false, "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "resolved": false, "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3739,22 +3771,19 @@ "version": "1.1.0", "resolved": false, "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "resolved": false, "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "resolved": false, "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -3885,8 +3914,7 @@ "version": "2.0.3", "resolved": false, "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -3900,7 +3928,6 @@ "resolved": false, "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3917,7 +3944,6 @@ "resolved": false, "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3926,15 +3952,13 @@ "version": "0.0.8", "resolved": false, "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "resolved": false, "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -3955,7 +3979,6 @@ "resolved": false, "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -4044,8 +4067,7 @@ "version": "1.0.1", "resolved": false, "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -4059,7 +4081,6 @@ "resolved": false, "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -4155,8 +4176,7 @@ "version": "5.1.2", "resolved": false, "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -4198,7 +4218,6 @@ "resolved": false, "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -4220,7 +4239,6 @@ "resolved": false, "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -4269,15 +4287,13 @@ "version": "1.0.2", "resolved": false, "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "resolved": false, "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", - "dev": true, - "optional": true + "dev": true } } }, diff --git a/package.json b/package.json index b5347df5ae..153884bbdd 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@types/json-stringify-safe": "^5.0.0", "@types/nanoid": "^2.1.0", "@types/node": "^10.14.4", + "axios": "^0.19.2", "console-testing-library": "^0.3.1", "eslint-config-react-app": "^5.0.1", "invariant": "^2.2.4", diff --git a/src/createAsyncThunk.test.ts b/src/createAsyncThunk.test.ts index dd8ffbb615..c6669623ae 100644 --- a/src/createAsyncThunk.test.ts +++ b/src/createAsyncThunk.test.ts @@ -6,6 +6,12 @@ import { import { configureStore } from './configureStore' import { AnyAction } from 'redux' +import { + mockConsole, + createConsole, + getLog +} from 'console-testing-library/pure' + describe('createAsyncThunk', () => { it('creates the action types', () => { const thunkActionCreator = createAsyncThunk('testType', async () => 42) @@ -106,6 +112,181 @@ describe('createAsyncThunk', () => { expect(errorAction.meta.requestId).toBe(generatedRequestId) expect(errorAction.meta.arg).toBe(args) }) + + it('dispatches an empty error when throwing a random object without serializedError properties', async () => { + const dispatch = jest.fn() + + const args = 123 + let generatedRequestId = '' + + const errorObject = { wny: 'dothis' } + + const thunkActionCreator = createAsyncThunk( + 'testType', + async (args: number, { requestId }) => { + generatedRequestId = requestId + throw errorObject + } + ) + + const thunkFunction = thunkActionCreator(args) + + try { + await thunkFunction(dispatch, () => {}, undefined) + } catch (e) {} + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + thunkActionCreator.pending(generatedRequestId, args) + ) + + expect(dispatch).toHaveBeenCalledTimes(2) + + const errorAction = dispatch.mock.calls[1][0] + expect(errorAction.error).toEqual({}) + expect(errorAction.meta.requestId).toBe(generatedRequestId) + expect(errorAction.meta.arg).toBe(args) + }) + + it('dispatches an action with a formatted error when throwing an object with known error keys', async () => { + const dispatch = jest.fn() + + const args = 123 + let generatedRequestId = '' + + const errorObject = { + name: 'Custom thrown error', + message: 'This is not necessary', + code: '400' + } + + const thunkActionCreator = createAsyncThunk( + 'testType', + async (args: number, { requestId }) => { + generatedRequestId = requestId + throw errorObject + } + ) + + const thunkFunction = thunkActionCreator(args) + + try { + await thunkFunction(dispatch, () => {}, undefined) + } catch (e) {} + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + thunkActionCreator.pending(generatedRequestId, args) + ) + + expect(dispatch).toHaveBeenCalledTimes(2) + + // Have to check the bits of the action separately since the error was processed + const errorAction = dispatch.mock.calls[1][0] + expect(errorAction.error).toEqual(miniSerializeError(errorObject)) + expect(Object.keys(errorAction.error)).not.toContain('stack') + expect(errorAction.meta.requestId).toBe(generatedRequestId) + expect(errorAction.meta.arg).toBe(args) + }) + + it('dispatches a rejected action with a customized payload when a user returns rejectWithValue()', async () => { + const dispatch = jest.fn() + + const args = 123 + let generatedRequestId = '' + + const errorPayload = { + errorMessage: + 'I am a fake server-provided 400 payload with validation details', + errors: [ + { field_one: 'Must be a string' }, + { field_two: 'Must be a number' } + ] + } + + const thunkActionCreator = createAsyncThunk( + 'testType', + async (args: number, { requestId, rejectWithValue }) => { + generatedRequestId = requestId + + return rejectWithValue(errorPayload) + } + ) + + const thunkFunction = thunkActionCreator(args) + + try { + await thunkFunction(dispatch, () => {}, undefined) + } catch (e) {} + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + thunkActionCreator.pending(generatedRequestId, args) + ) + + expect(dispatch).toHaveBeenCalledTimes(2) + + // Have to check the bits of the action separately since the error was processed + const errorAction = dispatch.mock.calls[1][0] + + expect(errorAction.error.message).toEqual('Rejected') + expect(errorAction.payload).toBe(errorPayload) + expect(errorAction.meta.arg).toBe(args) + }) + + it('dispatches a rejected action with a miniSerializeError when rejectWithValue conditions are not satisfied', async () => { + const dispatch = jest.fn() + + const args = 123 + let generatedRequestId = '' + + const error = new Error('Panic!') + + const errorPayload = { + errorMessage: + 'I am a fake server-provided 400 payload with validation details', + errors: [ + { field_one: 'Must be a string' }, + { field_two: 'Must be a number' } + ] + } + + const thunkActionCreator = createAsyncThunk( + 'testType', + async (args: number, { requestId, rejectWithValue }) => { + generatedRequestId = requestId + + try { + throw error + } catch (err) { + if (!err.response) { + throw err + } + return rejectWithValue(errorPayload) + } + } + ) + + const thunkFunction = thunkActionCreator(args) + + try { + await thunkFunction(dispatch, () => {}, undefined) + } catch (e) {} + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + thunkActionCreator.pending(generatedRequestId, args) + ) + + expect(dispatch).toHaveBeenCalledTimes(2) + + // Have to check the bits of the action separately since the error was processed + const errorAction = dispatch.mock.calls[1][0] + expect(errorAction.error).toEqual(miniSerializeError(error)) + expect(errorAction.payload).toEqual(undefined) + expect(errorAction.meta.requestId).toBe(generatedRequestId) + expect(errorAction.meta.arg).toBe(args) + }) }) describe('createAsyncThunk with abortController', () => { @@ -233,4 +414,46 @@ describe('createAsyncThunk with abortController', () => { meta: { aborted: true } }) }) + + describe('behaviour with missing AbortController', () => { + let keepAbortController: typeof AbortController + let freshlyLoadedModule: typeof import('./createAsyncThunk') + let restore: () => void + let nodeEnv: string + + beforeEach(() => { + keepAbortController = window.AbortController + delete window.AbortController + jest.resetModules() + freshlyLoadedModule = require('./createAsyncThunk') + restore = mockConsole(createConsole()) + nodeEnv = process.env.NODE_ENV! + process.env.NODE_ENV = 'development' + }) + + afterEach(() => { + process.env.NODE_ENV = nodeEnv + restore() + window.AbortController = keepAbortController + jest.resetModules() + }) + + test('calling `abort` on an asyncThunk works with a FallbackAbortController if no global abortController is not available', async () => { + const longRunningAsyncThunk = freshlyLoadedModule.createAsyncThunk( + 'longRunning', + async () => { + await new Promise(resolve => setTimeout(resolve, 30000)) + } + ) + + store.dispatch(longRunningAsyncThunk()).abort() + // should only log once, even if called twice + store.dispatch(longRunningAsyncThunk()).abort() + + expect(getLog().log).toMatchInlineSnapshot(` + "This platform does not implement AbortController. + If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'." + `) + }) + }) }) diff --git a/src/createAsyncThunk.ts b/src/createAsyncThunk.ts index eea94dd861..86764184ac 100644 --- a/src/createAsyncThunk.ts +++ b/src/createAsyncThunk.ts @@ -11,12 +11,18 @@ import { FallbackIfUnknown } from './tsHelpers' // @ts-ignore we need the import of these types due to a bundling issue. type _Keep = PayloadAction | ActionCreatorWithPreparedPayload -export type BaseThunkAPI = { +export type BaseThunkAPI< + S, + E, + D extends Dispatch = Dispatch, + RejectedValue = undefined +> = { dispatch: D getState: () => S extra: E requestId: string signal: AbortSignal + rejectWithValue(value: RejectedValue): RejectWithValue } /** @@ -29,15 +35,19 @@ export interface SerializedError { code?: string } -const commonProperties: (keyof SerializedError)[] = [ +const commonProperties: Array = [ 'name', 'message', 'stack', 'code' ] +class RejectWithValue { + constructor(public readonly value: RejectValue) {} +} + // Reworked from https://github.com/sindresorhus/serialize-error -export const miniSerializeError = (value: any): any => { +export const miniSerializeError = (value: any): SerializedError => { if (typeof value === 'object' && value !== null) { const simpleError: SerializedError = {} for (const property of commonProperties) { @@ -49,13 +59,14 @@ export const miniSerializeError = (value: any): any => { return simpleError } - return value + return { message: String(value) } } type AsyncThunkConfig = { state?: unknown dispatch?: Dispatch extra?: unknown + rejectValue?: unknown } type GetState = ThunkApiConfig extends { @@ -82,9 +93,16 @@ type GetDispatch = ThunkApiConfig extends { type GetThunkAPI = BaseThunkAPI< GetState, GetExtra, - GetDispatch + GetDispatch, + GetRejectValue > +type GetRejectValue = ThunkApiConfig extends { + rejectValue: infer RejectValue +} + ? RejectValue + : unknown + /** * * @param type @@ -101,8 +119,13 @@ export function createAsyncThunk< payloadCreator: ( arg: ThunkArg, thunkAPI: GetThunkAPI - ) => Promise | Returned + ) => + | Promise>> + | Returned + | RejectWithValue> ) { + type RejectedValue = GetRejectValue + const fulfilled = createAction( type + '/fulfilled', (result: Returned, requestId: string, arg: ThunkArg) => { @@ -125,11 +148,16 @@ export function createAsyncThunk< const rejected = createAction( type + '/rejected', - (error: Error, requestId: string, arg: ThunkArg) => { - const aborted = error && error.name === 'AbortError' + ( + error: Error | null, + requestId: string, + arg: ThunkArg, + payload?: RejectedValue + ) => { + const aborted = !!error && error.name === 'AbortError' return { - payload: undefined, - error: miniSerializeError(error), + payload, + error: miniSerializeError(error || 'Rejected'), meta: { arg, requestId, @@ -139,6 +167,34 @@ export function createAsyncThunk< } ) + let displayedWarning = false + + const AC = + typeof AbortController !== 'undefined' + ? AbortController + : class implements AbortController { + signal: AbortSignal = { + aborted: false, + addEventListener() {}, + dispatchEvent() { + return false + }, + onabort() {}, + removeEventListener() {} + } + abort() { + if (process.env.NODE_ENV === 'development') { + if (!displayedWarning) { + displayedWarning = true + console.info( + `This platform does not implement AbortController. +If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'.` + ) + } + } + } + } + function actionCreator(arg: ThunkArg) { return ( dispatch: GetDispatch, @@ -147,7 +203,7 @@ export function createAsyncThunk< ) => { const requestId = nanoid() - const abortController = new AbortController() + const abortController = new AC() let abortReason: string | undefined const abortedPromise = new Promise((_, reject) => @@ -173,9 +229,17 @@ export function createAsyncThunk< getState, extra, requestId, - signal: abortController.signal + signal: abortController.signal, + rejectWithValue(value: RejectedValue) { + return new RejectWithValue(value) + } }) - ).then(result => fulfilled(result, requestId, arg)) + ).then(result => { + if (result instanceof RejectWithValue) { + return rejected(null, requestId, arg, result.value) + } + return fulfilled(result, requestId, arg) + }) ]) } catch (err) { finalAction = rejected(err, requestId, arg) @@ -199,14 +263,23 @@ export function createAsyncThunk< }) } +type ActionTypesWithOptionalErrorAction = + | { error: any } + | { error?: never; payload: any } +type PayloadForActionTypesExcludingErrorActions = T extends { error: any } + ? never + : T extends { payload: infer P } + ? P + : never + /** * @alpha */ -export function unwrapResult( - returned: { error: any } | { payload: NonNullable } -): NonNullable { +export function unwrapResult( + returned: R +): PayloadForActionTypesExcludingErrorActions { if ('error' in returned) { throw returned.error } - return returned.payload + return (returned as any).payload } diff --git a/type-tests/files/createAsyncThunk.typetest.ts b/type-tests/files/createAsyncThunk.typetest.ts index 58f0bc22c8..261f459e59 100644 --- a/type-tests/files/createAsyncThunk.typetest.ts +++ b/type-tests/files/createAsyncThunk.typetest.ts @@ -1,7 +1,9 @@ import { createAsyncThunk, Dispatch, createReducer, AnyAction } from 'src' import { ThunkDispatch } from 'redux-thunk' -import { promises } from 'fs' -import { unwrapResult } from 'src/createAsyncThunk' +import { unwrapResult, SerializedError } from 'src/createAsyncThunk' + +import apiRequest, { AxiosError } from 'axios' +import { IsAny } from 'src/tsHelpers' function expectType(t: T) { return t @@ -25,7 +27,7 @@ const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, AnyAction> }) .addCase(async.rejected, (_, action) => { expectType>(action) - expectType(action.error) + expectType | undefined>(action.error) }) ) @@ -98,3 +100,84 @@ const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, AnyAction> // typings:expect-error defaultDispatch(fetchBooksTAC(1)) })() +/** + * returning a rejected action from the promise creator is possible + */ +;(async () => { + type ReturnValue = { data: 'success' } + type RejectValue = { data: 'error' } + + const fetchBooksTAC = createAsyncThunk< + ReturnValue, + number, + { + rejectValue: RejectValue + } + >('books/fetch', async (arg, { rejectWithValue }) => { + return rejectWithValue({ data: 'error' }) + }) + + const returned = await defaultDispatch(fetchBooksTAC(1)) + if (fetchBooksTAC.rejected.match(returned)) { + expectType(returned.payload) + expectType(returned.payload!) + } else { + expectType(returned.payload) + } +})() + +{ + interface Item { + name: string + } + + interface ErrorFromServer { + error: string + } + + interface CallsResponse { + data: Item[] + } + + const fetchLiveCallsError = createAsyncThunk< + Item[], + string, + { + rejectValue: ErrorFromServer + } + >('calls/fetchLiveCalls', async (organizationId, { rejectWithValue }) => { + try { + const result = await apiRequest.get( + `organizations/${organizationId}/calls/live/iwill404` + ) + return result.data.data + } catch (err) { + let error: AxiosError = err // cast for access to AxiosError properties + if (!error.response) { + // let it be handled as any other unknown error + throw err + } + return rejectWithValue(error.response && error.response.data) + } + }) + + defaultDispatch(fetchLiveCallsError('asd')).then(result => { + if (fetchLiveCallsError.fulfilled.match(result)) { + //success + expectType>(result) + expectType(result.payload) + } else { + expectType>(result) + if (result.payload) { + // rejected with value + expectType(result.payload) + } else { + // rejected by throw + expectType(result.payload) + expectType(result.error) + // typings:expect-error + expectType>(true) + } + } + }) +}