Skip to content

Commit 9e29ccc

Browse files
RFC createAsyncThunk: reject with typed value (#393)
* createAsyncThunk: add rejectWithValue function * Update docs on createAsyncThunk usage, add tests for rejectWithValue, fix rejected error return * implement AbortController stub for node, react native & IE Co-authored-by: Matt Sutkowski <[email protected]>
1 parent 4417317 commit 9e29ccc

File tree

8 files changed

+748
-69
lines changed

8 files changed

+748
-69
lines changed

docs/api/createAsyncThunk.md

Lines changed: 201 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ For example, a `type` argument of `'users/requestStatus'` will generate these ac
6464

6565
### `payloadCreator`
6666

67-
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.
67+
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.
6868

6969
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.
7070

@@ -77,6 +77,7 @@ The `payloadCreator` function will be called with two arguments:
7777
- `extra`: the "extra argument" given to the thunk middleware on setup, if available
7878
- `requestId`: a unique string ID value that was automatically generated to identify this request sequence
7979
- `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.
80+
- `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.
8081

8182
The logic in the `payloadCreator` function may use any of these values as needed to calculate the result.
8283

@@ -90,7 +91,8 @@ When dispatched, the thunk will:
9091
- call the `payloadCreator` callback and wait for the returned promise to settle
9192
- when the promise settles:
9293
- if the promise resolved successfully, dispatch the `fulfilled` action with the promise value as `action.payload`
93-
- if the promise failed, dispatch the `rejected` action with a serialized version of the error value as `action.error`
94+
- 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`
95+
- 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`
9496
- Return a fulfilled promise containing the final dispatched action (either the `fulfilled` or `rejected` action object)
9597

9698
## Promise Lifecycle Actions
@@ -99,7 +101,7 @@ When dispatched, the thunk will:
99101

100102
The action creators will have these signatures:
101103

102-
```ts
104+
```typescript
103105
interface SerializedError {
104106
name?: string
105107
message?: string
@@ -136,6 +138,17 @@ interface RejectedAction<ThunkArg> {
136138
}
137139
}
138140

141+
interface RejectedWithValueAction<ThunkArg, RejectedValue> {
142+
type: string
143+
payload: RejectedValue
144+
error: { message: 'Rejected' }
145+
meta: {
146+
requestId: string
147+
arg: ThunkArg
148+
aborted: boolean
149+
}
150+
}
151+
139152
type Pending = <ThunkArg>(
140153
requestId: string,
141154
arg: ThunkArg
@@ -151,6 +164,11 @@ type Rejected = <ThunkArg>(
151164
requestId: string,
152165
arg: ThunkArg
153166
) => RejectedAction<ThunkArg>
167+
168+
type RejectedWithValue = <ThunkArg, RejectedValue>(
169+
requestId: string,
170+
arg: ThunkArg
171+
) => RejectedWithValueAction<ThunkArg, RejectedValue>
154172
```
155173
156174
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(
299317
300318
## Examples
301319
302-
Requesting a user by ID, with loading state, and only one request at a time:
320+
- Requesting a user by ID, with loading state, and only one request at a time:
303321
304322
```js
305323
import { createAsyncThunk, createSlice, unwrapResult } from '@reduxjs/toolkit'
@@ -352,7 +370,7 @@ const UsersComponent = () => {
352370

353371
const fetchOneUser = async userId => {
354372
try {
355-
const resultAction = dispatch(fetchUserById(userId))
373+
const resultAction = await dispatch(fetchUserById(userId))
356374
const user = unwrapResult(resultAction)
357375
showToast('success', `Fetched ${user.name}`)
358376
} catch (err) {
@@ -363,3 +381,181 @@ const UsersComponent = () => {
363381
// render UI here
364382
}
365383
```
384+
385+
- Using rejectWithValue to access a custom rejected payload in a component
386+
387+
```js
388+
import { createAsyncThunk, createSlice, unwrapResult } from '@reduxjs/toolkit'
389+
import { userAPI } from './userAPI'
390+
391+
const updateUser = createAsyncThunk(
392+
'users/update',
393+
async (userData, { rejectWithValue }) => {
394+
const { id, ...fields } = userData
395+
try {
396+
const response = await userAPI.updateById(id, fields)
397+
return response.data.user
398+
} catch (err) {
399+
// Note: this is an example assuming the usage of axios. Other fetching libraries would likely have different implementations
400+
if (!err.response) {
401+
throw err
402+
}
403+
404+
return rejectWithValue(err.response.data)
405+
}
406+
}
407+
)
408+
409+
const usersSlice = createSlice({
410+
name: 'users',
411+
initialState: {
412+
entities: {},
413+
error: null
414+
},
415+
reducers: {},
416+
extraReducers: {
417+
[updateUser.fullfilled]: (state, action) => {
418+
const user = action.payload
419+
state.entities[user.id] = user
420+
},
421+
[updateUser.rejected]: (state, action) => {
422+
if (action.payload) {
423+
// If a rejected action has a payload, it means that it was returned with rejectWithValue
424+
state.error = action.payload.errorMessage
425+
} else {
426+
state.error = action.error
427+
}
428+
}
429+
}
430+
})
431+
432+
const UsersComponent = () => {
433+
const { users, loading, error } = useSelector(state => state.users)
434+
const dispatch = useDispatch()
435+
436+
// This is an example of an onSubmit handler using Formik meant to demonstrate accessing the payload of the rejected action
437+
const handleUpdateUser = async (values, formikHelpers) => {
438+
const resultAction = await dispatch(updateUser(values))
439+
if (updateUser.fulfilled.match(resultAction)) {
440+
const user = unwrapResult(resultAction)
441+
showToast('success', `Updated ${user.name}`)
442+
} else {
443+
if (resultAction.payload) {
444+
// This is assuming the api returned a 400 error with a body of { errorMessage: 'Validation errors', field_errors: { field_name: 'Should be a string' } }
445+
formikHelpers.setErrors(resultAction.payload.field_errors)
446+
} else {
447+
showToast('error', `Update failed: ${resultAction.error}`)
448+
}
449+
}
450+
}
451+
452+
// render UI here
453+
}
454+
```
455+
456+
- TypeScript: Using rejectWithValue to access a custom rejected payload in a component
457+
_Note: this is a contrived example assuming our userAPI only ever throws validation-specific errors_
458+
459+
```typescript
460+
import { createAsyncThunk, createSlice, unwrapResult } from '@reduxjs/toolkit'
461+
import { userAPI } from './userAPI'
462+
import { AppDispatch, RootState } from '../store'
463+
import { FormikHelpers } from 'formik'
464+
465+
// Sample types that will be used
466+
interface User {
467+
first_name: string
468+
last_name: string
469+
email: string
470+
}
471+
472+
interface ValidationErrors {
473+
errorMessage: string
474+
field_errors: Record<string, string>
475+
}
476+
477+
interface UpdateUserResponse {
478+
user: User
479+
success: boolean
480+
}
481+
482+
const updateUser = createAsyncThunk<
483+
User,
484+
Partial<User>,
485+
{
486+
rejectValue: ValidationErrors
487+
}
488+
>('users/update', async (userData, { rejectWithValue }) => {
489+
try {
490+
const { id, ...fields } = userData
491+
const response = await userAPI.updateById<UpdateUserResponse>(id, fields)
492+
return response.data.user
493+
} catch (err) {
494+
let error: AxiosError<ValidationErrors> = err // cast the error for access
495+
if (!error.response) {
496+
throw err
497+
}
498+
// We got validation errors, let's return those so we can reference in our component and set form errors
499+
return rejectWithValue(error.response.data)
500+
}
501+
})
502+
503+
interface UsersState {
504+
error: string | null
505+
entities: Record<string, User>
506+
}
507+
508+
const initialState: UsersState = {
509+
entities: {},
510+
error: null
511+
}
512+
513+
const usersSlice = createSlice({
514+
name: 'users',
515+
initialState,
516+
reducers: {},
517+
extraReducers: builder => {
518+
// The `builder` callback form is used here because it provides correctly typed reducers from the action creators
519+
builder.addCase(updateUser.fulfilled, (state, { payload }) => {
520+
state.entities[payload.id] = payload
521+
})
522+
builder.addCase(updateUser.rejected, (state, action) => {
523+
if (action.payload) {
524+
// Being that we passed in ValidationErrors to rejectType in `createAsyncThunk`, the payload will be available here.
525+
state.error = action.payload.errorMessage
526+
} else {
527+
state.error = action.error
528+
}
529+
})
530+
}
531+
})
532+
533+
const UsersComponent = () => {
534+
const { users, loading, error } = useSelector(
535+
(state: RootState) => state.users
536+
)
537+
const dispatch: AppDispatch = useDispatch()
538+
539+
// This is an example of an onSubmit handler using Formik meant to demonstrate accessing the payload of the rejected action
540+
const handleUpdateUser = async (
541+
values: FormValues,
542+
formikHelpers: FormikHelpers<FormValues>
543+
) => {
544+
const resultAction = await dispatch(updateUser(values))
545+
if (updateUser.fulfilled.match(resultAction)) {
546+
// user will have a type signature of User as we passed that as the Returned parameter in createAsyncThunk
547+
const user = unwrapResult(resultAction)
548+
showToast('success', `Updated ${user.name}`)
549+
} else {
550+
if (resultAction.payload) {
551+
// Being that we passed in ValidationErrors to rejectType in `createAsyncThunk`, those types will be available here.
552+
formikHelpers.setErrors(resultAction.payload.field_errors)
553+
} else {
554+
showToast('error', `Update failed: ${resultAction.error}`)
555+
}
556+
}
557+
}
558+
559+
// render UI here
560+
}
561+
```

docs/usage/usage-with-typescript.md

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -420,14 +420,14 @@ const fetchUserById = createAsyncThunk(
420420
const lastReturnedAction = await store.dispatch(fetchUserById(3))
421421
```
422422

423-
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.
423+
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.
424424

425-
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?}`.
425+
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?}`.
426426

427-
```ts {2-12}
427+
```ts
428428
const fetchUserById = createAsyncThunk<
429429
// Return type of the payload creator
430-
Promise<MyData>,
430+
MyData,
431431
// First argument to the payload creator
432432
number,
433433
{
@@ -447,7 +447,98 @@ const fetchUserById = createAsyncThunk<
447447
})
448448
```
449449

450-
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`.
450+
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.
451+
452+
```ts
453+
interface MyKnownError {
454+
errorMessage: string
455+
// ...
456+
}
457+
interface UserAttributes {
458+
id: string
459+
first_name: string
460+
last_name: string
461+
email: string
462+
}
463+
464+
const updateUser = createAsyncThunk<
465+
// Return type of the payload creator
466+
MyData,
467+
// First argument to the payload creator
468+
UserAttributes,
469+
// Types for ThunkAPI
470+
{
471+
extra: {
472+
jwt: string
473+
}
474+
rejectValue: MyKnownError
475+
}
476+
>('users/update', async (user, thunkApi) => {
477+
const { id, ...userData } = user
478+
const response = await fetch(`https://reqres.in/api/users/${id}`, {
479+
method: 'PUT',
480+
headers: {
481+
Authorization: `Bearer ${thunkApi.extra.jwt}`
482+
},
483+
body: JSON.stringify(userData)
484+
})
485+
if (response.status === 400) {
486+
// Return the known error for future handling
487+
return thunkApi.rejectWithValue((await response.json()) as MyKnownError)
488+
}
489+
return (await response.json()) as MyData
490+
})
491+
```
492+
493+
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.
494+
495+
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:
496+
497+
- In a reducer
498+
499+
```ts
500+
const usersSlice = createSlice({
501+
name: 'users',
502+
initialState: {
503+
entities: {},
504+
error: null
505+
},
506+
reducers: {},
507+
extraReducers: builder => {
508+
builder.addCase(updateUser.fulfilled, (state, { payload }) => {
509+
state.entities[payload.id] = payload
510+
})
511+
builder.addCase(updateUser.rejected, (state, action) => {
512+
if (action.payload) {
513+
// Since we passed in `MyKnownError` to `rejectType` in `updateUser`, the type information will be available here.
514+
state.error = action.payload.errorMessage
515+
} else {
516+
state.error = action.error
517+
}
518+
})
519+
}
520+
})
521+
```
522+
523+
- In a component
524+
525+
```ts
526+
const handleUpdateUser = async userData => {
527+
const resultAction = await dispatch(updateUser(userData))
528+
if (updateUser.fulfilled.match(resultAction)) {
529+
const user = unwrapResult(resultAction)
530+
showToast('success', `Updated ${user.name}`)
531+
} else {
532+
if (resultAction.payload) {
533+
// Since we passed in `MyKnownError` to `rejectType` in `updateUser`, the type information will be available here.
534+
// Note: this would also be a good place to do any handling that relies on the `rejectedWithValue` payload, such as setting field errors
535+
showToast('error', `Update failed: ${resultAction.payload.errorMessage}`)
536+
} else {
537+
showToast('error', `Update failed: ${resultAction.error.message}`)
538+
}
539+
}
540+
}
541+
```
451542

452543
## `createEntityAdapter`
453544

0 commit comments

Comments
 (0)