Concept
Middleware established that async logic in Redux has to live in middleware, since reducers must stay pure and synchronous, configureStore includes redux-thunk middleware by default specifically to enable dispatching functions. createAsyncThunk is Redux Toolkit's generalized, well-tested wrapper around that exact pattern for the extremely common case of "dispatch an action, run an async operation, dispatch a different action based on the outcome."
import { createAsyncThunk } from "@reduxjs/toolkit";
const fetchUser = createAsyncThunk("user/fetch", async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json(); // becomes the fulfilled action's payload
});One call to createAsyncThunk generates three distinct, real action types from that single "user/fetch" prefix: "user/fetch/pending", "user/fetch/fulfilled", and "user/fetch/rejected", plus a thunk function (fetchUser) that, when dispatched, orchestrates firing the right one of these at the right time.
const fetchUser = createAsyncThunk('user/fetch', async (id) => {const res = await api.getUser(id);return res.data;});store.dispatch(fetchUser(1));
Dispatching a thunk created via createAsyncThunk looks like a single dispatch, but confirmed by execution, it actually fires a SEQUENCE of real, distinct actions.
Confirmed: pending fires synchronously, before the dispatch call even returns
console.log(store.getState().user.status); // "idle"
const promise = store.dispatch(fetchUser(1));
console.log(store.getState().user.status); // "loading", ALREADY, before the fetch has resolvedConfirmed by running this exact sequence: the pending action dispatches immediately and synchronously, before the actual async function body (fetch(...)) has had any chance to resolve. The awaited work only begins after pending has already been dispatched and processed by the reducer, this is what makes "loading" UI states reliable: there's no gap where the UI doesn't yet know a request has started.
Wiring the three actions to a slice via extraReducers
const userSlice = createSlice({
name: "user",
initialState: { status: "idle", data: null, error: null },
reducers: {}, // no synchronous actions needed for this slice
extraReducers(builder) {
builder
.addCase(fetchUser.pending, (state) => { state.status = "loading"; })
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = "succeeded";
state.data = action.payload; // whatever the thunk RETURNED
})
.addCase(fetchUser.rejected, (state,
extraReducers exists specifically for reacting to actions a slice didn't itself define via its reducers object, fetchUser.pending/.fulfilled/.rejected are exactly that: actions generated externally by createAsyncThunk, not by this slice. The builder.addCase(...) API works exactly like createSlice's own reducers, Immer-powered "mutating" syntax applies here too, per createSlice.
Handling expected failures with rejectWithValue
const fetchUser = createAsyncThunk("user/fetch", async (userId, thunkAPI) => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
return thunkAPI.rejectWithValue({ message: "User not found", status: response.status });
}
return response.json();
});Confirmed by execution: calling thunkAPI.rejectWithValue(data) and returning it dispatches the rejected action, with action.payload set to exactly that data, distinct from an unexpected thrown error (a genuine network failure, a bug), which also dispatches rejected but populates action.error instead, with action.payload left undefined. rejectWithValue is specifically for expected, application-level failures (a 404, a validation error from the server) that you want to handle with the same structured payload shape as a success.
Try It
Predict the outcome before checking the solution.
const fetchUser = createAsyncThunk("user/fetch", async (id, thunkAPI) => {
if (id === 999) {
return thunkAPI.rejectWithValue({ message: "not found" });
}
await new Promise((r) => setTimeout(r, 10));
return { id, name: "Ada" };
});
// ... slice wired exactly as above ...
await store.dispatch(fetchUser(1));
console.log(store.getState().user); // call 1
After the second dispatch (the rejected one), what does store.getState().user look like, specifically, is data cleared back to null?
Solution
{ status: 'failed', data: { id: 1, name: 'Ada' }, error: { message: 'not found' } }, data is not cleared.
This is confirmed by running the exact sequence, and it's a common real-world gotcha: the rejected case's reducer, as written above, only updates status and error, it never touches data. Since Immer's structural sharing (from createSlice) means untouched fields keep their prior value by default, data silently retains whatever it held from the previous successful dispatch. If stale data showing alongside a "failed" status would be a UI bug (very often it is, you don't want to show old user data next to an error message implying nothing loaded), the rejected case needs to explicitly reset data: null itself; Redux Toolkit doesn't do this for you automatically.
Implement It Yourself
Sketch a simplified version of what createAsyncThunk generates, using only pieces already covered in this domain:
function myCreateAsyncThunk(typePrefix, payloadCreator) {
const pending = { type: `${typePrefix}/pending` };
const fulfilled = (payload) => ({ type: `${typePrefix}/fulfilled`, payload });
const rejected = (error) => ({ type: `${typePrefix}/rejected`, error });
function thunk(arg) {
return async (dispatch, getState) => { // the THUNK shape redux-thunk middleware recognizes
dispatch(pending); // fires FIRST, synchronously
try {
This makes the three-actions-from-one-call mechanism concrete: thunk(arg) returns a function, exactly the "dispatch a function, not a plain object" pattern redux-thunk middleware (covered conceptually in Middleware) specifically exists to intercept and execute, calling it with dispatch/getState rather than passing it to the reducer.
Under the Hood
This directly builds on Middleware's "middleware can dispatch further actions" mechanism, createAsyncThunk's generated thunk function IS exactly that pattern, generalized and battle-tested. It produces plain action objects consumed by createSlice's extraReducers, and represents the "simple, single-async-operation" end of the async-handling spectrum that Saga Effects sits at the more complex, generator-based end of, both solve the same fundamental problem (getting async results into the store via dispatched actions) with different tradeoffs in complexity and testability.
Common Mistakes
1. Forgetting that rejected's payload lives in action.payload, not action.error, when using rejectWithValue
.addCase(fetchUser.rejected, (state, action) => {
state.error = action.error.message; // ❌ WRONG when rejectWithValue was used, action.payload holds the value, action.error is for UNEXPECTED thrown errors
})rejectWithValue(data) puts data in action.payload. An unhandled thrown exception inside the thunk instead populates action.error (a serialized error object) with action.payload left undefined. Mixing these up means reading undefined in one of the two failure paths.
2. Not resetting derived/stale state in the rejected (or pending) case
.addCase(fetchUser.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload;
// ❌ forgot: state.data = null, stale data from a PREVIOUS success silently persists
})As shown in the Try It exercise, Immer's structural sharing means any field the reducer case doesn't explicitly touch keeps its old value, this is often exactly what you want (don't blow away unrelated state), but for fields like data that are logically tied to the current request's success/failure, it requires an explicit reset.
3. Dispatching a createAsyncThunk thunk without awaiting or handling its returned promise when the result matters
store.dispatch(fetchUser(1)); // fire-and-forget, fine if you don't need the OUTCOME here
// vs.
const result = await store.dispatch(fetchUser(1));
if (fetchUser.fulfilled.match(result)) { /* ... */ } // needed if THIS call site cares about success/failureDispatching a thunk always updates the store correctly regardless, but if the calling code itself needs to branch on success/failure right at the call site (not just via the store's state), it needs to await the dispatch and check the returned action's type (or use the generated .fulfilled.match()/.rejected.match() type-guard helpers).
Best Practices
- Use
rejectWithValuefor any expected, application-level failure (validation errors, 404s, business-rule rejections) so they flow throughaction.payloadwith a predictable, structured shape, reserve unhandled throws for genuinely unexpected failures. - Explicitly reset every field logically tied to a request's outcome in both
fulfilledandrejectedcases, don't rely on structural sharing to "clean up" stale data from a previous request. - Wire
createAsyncThunkactions via a slice'sextraReducers, not its ownreducersobject,extraReducersis specifically designed for actions generated outside the slice itself.
Performance Tips
createAsyncThunkincludes built-in request de-duplication/cancellation support (viaconditionandAbortControllerintegration) for cases where a rapidly re-dispatched thunk (e.g. from fast user typing) would otherwise pile up redundant, overlapping requests.- The generated
pending/fulfilled/rejectedaction objects are lightweight plain objects, the actual cost of a thunk is entirely the async work it performs, not any overhead fromcreateAsyncThunkitself.
