Skip to content

RFC createAsyncThunk: reject with typed value #393

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

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
206 changes: 201 additions & 5 deletions docs/api/createAsyncThunk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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
Expand All @@ -99,7 +101,7 @@ When dispatched, the thunk will:

The action creators will have these signatures:

```ts
```typescript
interface SerializedError {
name?: string
message?: string
Expand Down Expand Up @@ -136,6 +138,17 @@ interface RejectedAction<ThunkArg> {
}
}

interface RejectedWithValueAction<ThunkArg, RejectedValue> {
type: string
payload: RejectedValue
error: { message: 'Rejected' }
meta: {
requestId: string
arg: ThunkArg
aborted: boolean
}
}

type Pending = <ThunkArg>(
requestId: string,
arg: ThunkArg
Expand All @@ -151,6 +164,11 @@ type Rejected = <ThunkArg>(
requestId: string,
arg: ThunkArg
) => RejectedAction<ThunkArg>

type RejectedWithValue = <ThunkArg, RejectedValue>(
requestId: string,
arg: ThunkArg
) => RejectedWithValueAction<ThunkArg, RejectedValue>
```

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:
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand All @@ -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<string, string>
}

interface UpdateUserResponse {
user: User
success: boolean
}

const updateUser = createAsyncThunk<
User,
Partial<User>,
{
rejectValue: ValidationErrors
}
>('users/update', async (userData, { rejectWithValue }) => {
try {
const { id, ...fields } = userData
const response = await userAPI.updateById<UpdateUserResponse>(id, fields)
return response.data.user
} catch (err) {
let error: AxiosError<ValidationErrors> = 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<string, User>
}

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<FormValues>
) => {
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
}
```
101 changes: 96 additions & 5 deletions docs/usage/usage-with-typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
MyData,
// First argument to the payload creator
number,
{
Expand All @@ -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`

Expand Down
Loading