In Redux Toolkit, which actions does one createAsyncThunk call dispatch, and how does rejectWithValue change the rejected action?
answer
- three suffixes on one type prefix
- before, then success or failure
- thrown errors get serialised
- a returned value lands in payload
basics
~20 sA 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 linesimport { 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
Recall the three lifecycle types, pending, fulfilled and rejected, and that slices handle them in extraReducers with builder.addCase.
Explain error serialisation and why rejectWithValue keeps server data, and show why the dispatched promise resolves on failure until you call unwrap.
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.
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