skip to content

In Redux Toolkit, which actions does one createAsyncThunk call dispatch, and how does rejectWithValue change the rejected action?

level: middleimportance: must knowfreq 63%

answer

  1. three suffixes on one type prefix
  2. before, then success or failure
  3. thrown errors get serialised
  4. a returned value lands in payload

basics

~20 s

A createAsyncThunk dispatches typePrefix/pending before the payload creator runs, then typePrefix/fulfilled with the resolved value or typePrefix/rejected on failure. rejectWithValue puts your chosen value in the rejected action's payload instead of losing it to error serialisation.

solid answer

~40 s

`createAsyncThunk('profile/save', async (arg, thunkAPI) => {...})` returns a thunk action creator. Dispatching it first dispatches `profile/save/pending`, then awaits the payload creator; a resolved value becomes `profile/save/fulfilled` with that value as `payload`, and a thrown error becomes `profile/save/rejected` with a **serialised** `action.error` holding only fields such as `name`, `message`, `stack` and `code`. Every lifecycle action carries `meta.arg` and `meta.requestId`. If the server returns useful error data, such as field validation errors, you `return thunkAPI.rejectWithValue(body)`: the action is still `rejected`, but `payload` holds your value and `meta.rejectedWithValue` is `true`. The dispatch returns a promise that always resolves to the final action; call `.unwrap()` on it to get the payload or have it throw.

code

ts · 21 lines
ts
import { createAsyncThunk } from '@reduxjs/toolkit'

interface Profile { name: string; email: string }
interface SaveError { fieldErrors: Record<string, string> }

export const saveProfile = createAsyncThunk<
  Profile,
  Profile,
  { rejectValue: SaveError }
>('profile/save', async (profile, { rejectWithValue, signal }) => {
  const res = await fetch('/api/profile', {
    method: 'PUT',
    body: JSON.stringify(profile),
    signal,
  })
  if (res.status === 422) {
    return rejectWithValue((await res.json()) as SaveError)
  }
  if (!res.ok) throw new Error(`Save failed: ${res.status}`)
  return (await res.json()) as Profile
})

go deeper

for a junior

Recall the three lifecycle types, pending, fulfilled and rejected, and that slices handle them in extraReducers with builder.addCase.

for a middle

Explain error serialisation and why rejectWithValue keeps server data, and show why the dispatched promise resolves on failure until you call unwrap.

for a senior

Design the error path end to end: typed rejectValue, status and field errors in the slice, unwrap in the component, and condition to skip redundant requests.

for a principal

Judge when hand-rolled createAsyncThunk calls for server data should give way to a dedicated data-fetching layer, and what caching and deduplication the team would otherwise rebuild.

## What createAsyncThunk generates Hand-written async Redux code dispatches a "request started" action, performs the call, then dispatches a "succeeded" or "failed" action. `createAsyncThunk` from `@reduxjs/toolkit` generates that pattern from two arguments: - a **type prefix**, such as `'profile/save'`; - a **payload creator**, an async function `(arg, thunkAPI) => result`. It returns a **thunk action creator**. Calling it with an argument returns a thunk, which the thunk middleware (installed by `configureStore`) runs when you dispatch it. The creator also exposes three plain action creators, `.pending`, `.fulfilled` and `.rejected`, which slices use to handle the lifecycle. ## The lifecycle of one dispatch 1. If you supplied a `condition` option, it runs first; returning `false` cancels the whole call and, by default, nothing is dispatched. 2. `profile/save/pending` is dispatched, with `meta.arg` (the argument) and `meta.requestId` (a unique id for this call). 3. The payload creator runs with `thunkAPI`, which contains `dispatch`, `getState`, `extra`, `requestId`, `signal`, `abort`, `rejectWithValue` and `fulfillWithValue`. 4. Exactly one of the following is dispatched: - `profile/save/fulfilled` with `payload` = the resolved value; - `profile/save/rejected` with `error` = a serialised copy of the thrown error. The slice handles these in `extraReducers`, because they are defined outside it: ```ts builder .addCase(saveProfile.pending, (state) => { state.status = 'saving' }) .addCase(saveProfile.fulfilled, (state, action) => { state.status = 'idle'; state.profile = action.payload }) .addCase(saveProfile.rejected, (state, action) => { state.status = 'failed' state.fieldErrors = action.payload?.fieldErrors ?? {} }) ``` ## Why rejectWithValue exists When the payload creator **throws**, RTK passes the error through a serialiser so the action stays plain data. For an object it keeps only string-valued `name`, `message`, `stack` and `code`; everything else is dropped. A server's `422` body such as `{ fieldErrors: { email: 'taken' } }` would therefore vanish if you threw it. `rejectWithValue(value)` is the escape hatch. Returning it from the payload creator produces a `rejected` action where: | Field | Thrown error | `rejectWithValue(value)` | |---|---|---| | `action.type` | `.../rejected` | `.../rejected` | | `action.payload` | `undefined` | `value` | | `action.error` | serialised error | `{ message: 'Rejected' }` | | `action.meta.rejectedWithValue` | `false` | `true` | In TypeScript you declare the value's type with the `rejectValue` generic, so `action.payload` is typed in the reducer. The mirror helper, `fulfillWithValue(value, meta)`, lets a successful call attach extra `meta`. ## What the dispatch returns `dispatch(saveProfile(form))` returns a promise that **always resolves** with the final action, fulfilled or rejected; it does not reject on failure. That keeps an unhandled rejection from escaping, but it means `try/await/catch` around the dispatch never catches anything. For component logic that must branch on the outcome: - call `.unwrap()`, which resolves to the fulfilled `payload` or **throws** — the `rejectWithValue` value if there is one, otherwise the serialised error; - or test the action with `saveProfile.fulfilled.match(result)`. The returned promise also carries `abort()`, `requestId` and `arg`. ## Typing and reading the lifecycle in the slice The full generic signature is `createAsyncThunk<Returned, ThunkArg, { rejectValue, state, extra }>`. Declaring `state` types `getState()`, and declaring `extra` types the dependency injected through the thunk middleware. In the slice, a few helpers keep lifecycle handling short: - `saveProfile.settled` matches both `fulfilled` and `rejected`, which is handy with `builder.addMatcher` to clear a spinner in one place; - `isRejectedWithValue(action)`, exported by RTK, tells a rejection carrying your value apart from a thrown one; - `action.meta.requestStatus` is `'pending'`, `'fulfilled'` or `'rejected'` on the respective action. Because `pending` already carries `meta.arg`, the slice can record which item is being saved without any extra action. ## Common mistakes - Catching the error inside the payload creator and **returning** it, which dispatches `fulfilled` with an error as the payload. - Throwing the parsed response body and expecting to find it in `action.error`. - Wrapping `await dispatch(thunk())` in `try/catch` without `.unwrap()`. - Handling the lifecycle actions under `reducers` instead of `extraReducers`. A strong answer lists the three action types in order, explains what serialisation drops, and shows `rejectWithValue` plus `.unwrap()` as the pair that carries server errors to both the slice and the calling component.

  • Why does 'await dispatch(fetchThing())' inside try/catch never reach the catch block in Redux Toolkit?
    The promise returned by dispatching a `createAsyncThunk` always resolves with the final action, including `rejected` ones, so there is nothing to catch. Call `.unwrap()` on it to get a promise that rejects with the `rejectWithValue` payload or the serialised error, or test the result with `fetchThing.rejected.match(action)`.
  • What does the condition option of createAsyncThunk do, and what is dispatched when it returns false?
    `condition(arg, { getState, extra })` runs before `pending`. Returning `false` cancels the call, which is useful to skip a fetch that is already loading or cached. By default no action at all is dispatched; with `dispatchConditionRejection: true` a `rejected` action with `meta.condition` set to `true` is dispatched instead.
  • Where does a Redux Toolkit thunk get its API client without importing it directly?
    From `thunkAPI.extra`, which is whatever you pass as `getDefaultMiddleware({ thunk: { extraArgument } })`. Injecting the client there lets tests supply a fake without module mocking.

saying these in an interview costs you the question

  • A thrown response body arrives intact in action.error
  • Awaiting the dispatched thunk rejects when the request fails
  • rejectWithValue dispatches the fulfilled action with an error payload
  • Lifecycle actions belong in the slice's reducers field
  • Catching the error and returning it marks the thunk as rejected