Redux Toolkit 09 ⚛️ Async Thunks — Handling asynchronous API requests with createAsyncThunk
createAsyncThunk is the Redux Toolkit function that generates an async action creator. It accepts a type prefix and a payload creator function, and it returns a thunk that dispatches three actions automatically: pending when the request starts, fulfilled when it succeeds, and rejected when it fails . The previous chapter covered the extraReducers builder that handles those three actions; this chapter covers the thunk itself: the payload creator, the thunkAPI object, the unwrap method, and the typed hooks that dispatch the thunk and read its state.
The function replaces the manual pattern of dispatching a “loading” action, performing the request, and dispatching either a “success” action or an “error” action. The three actions are generated from a single call, the lifecycle is consistent across every thunk in the application, and the pending/fulfilled/rejected actions can be handled by the slice’s extraReducers without any additional action creators.
Key point: The payload creator receives two arguments: the argument passed to the thunk when it is dispatched, and a thunkAPI object with dispatch, getState, rejectWithValue, signal, and extra. The rejectWithValue function returns a rejected action with a typed payload, which is how the slice receives a structured error rather than an Error object. The unwrap method on the returned promise resolves with the payload or rejects with the error, which is how a component handles the result of a dispatch.
Why createAsyncThunk exists
The lifecycle-boilerplate problem.
Without createAsyncThunk, an async action requires three separate action types and three action creators, and every thunk in the application repeats the same pattern: dispatch the pending action, try the request, dispatch the fulfilled action or the rejected action. The pattern is error-prone and inconsistent across features. createAsyncThunk generates the three action types from the type prefix and dispatches them automatically. The slice handles them in extraReducers with addCase(thunk.pending, ...), addCase(thunk.fulfilled, ...), and addCase(thunk.rejected, ...) .
The error-handling problem.
A fetch response with a 500 status resolves successfully unless the function checks response.ok and throws. The thunk’s payload creator must perform this check, and the error must reach the rejected action with a useful message. The rejectWithValue helper allows the payload creator to return a typed value as the rejection payload, which the slice reads in the rejected handler. The error property of the rejected action contains the Error object when rejectWithValue is not used, and the payload property contains the value when it is .
The cancellation problem.
A request that is no longer needed—because the user navigated away or a newer request superseded it—should be aborted. The thunkAPI.signal is an AbortSignal that the payload creator can pass to fetch. The condition option can prevent the thunk from running when the state indicates it should not, and the signal is aborted when the component that dispatched the thunk unmounts if the thunk is used with the useQuery-style lifecycle. The cancellation produces a rejected action with meta.aborted set to true, which the slice can distinguish from a real failure .
a. The payload creator and thunkAPI
The payload creator is an async function that receives the thunk’s argument and the thunkAPI object. It returns a promise that resolves with the payload or rejects with an error.
import { createAsyncThunk } from '@reduxjs/toolkit'
interface User {
id: number
name: string
email: string
}
interface FetchUserError {
message: string
status: number
}
export const fetchUser = createAsyncThunk<
User, // Return type
number, // Argument type
{ rejectValue: FetchUserError } // ThunkAPI config
>(
'user/fetch',
async (userId, { rejectWithValue, signal }) => {
const response = await fetch(`/api/users/${userId}`, { signal })
if (!response.ok) {
// Return a typed rejection payload
return rejectWithValue({
message: `Failed to fetch user ${userId}`,
status: response.status,
})
}
return response.json() as Promise<User>
}
)
The three type parameters declare the return type, the argument type, and the thunk API configuration. The rejectValue type tells TypeScript that the rejected action’s payload is a FetchUserError, which the slice’s rejected handler receives with the correct type . The signal is the AbortSignal from the thunk API, passed to fetch so that the request is aborted when the thunk is cancelled.
The thunkAPI object also contains dispatch, getState, and extra. The dispatch allows the payload creator to dispatch other actions, which is useful for cross-slice coordination. The getState allows the payload creator to read the current state, which is useful for reading a token from the auth slice. The extra is the value passed to the configureStore middleware or the thunk.extraArgument option, which is used for injecting dependencies like an API client .
export const fetchProfile = createAsyncThunk<User, void, { state: RootState }>(
'user/fetchProfile',
async (_, { getState, dispatch }) => {
const token = getState().auth.token
const response = await fetch('/api/profile', {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) throw new Error('Failed to fetch profile')
return response.json()
}
)
The { state: RootState } type parameter tells the thunk that getState() returns the full store state, so getState().auth.token is typed correctly. The token is read at dispatch time, not at the time the thunk is defined .
b. The three lifecycle actions
Every createAsyncThunk generates three action creators: pending, fulfilled, and rejected. Each is dispatched automatically when the corresponding phase of the request occurs.
// The thunk's action creators
fetchUser.pending // Dispatched when the request starts
fetchUser.fulfilled // Dispatched when the request succeeds
fetchUser.rejected // Dispatched when the request fails
// The pending action's shape
// { type: 'user/fetch/pending', payload: undefined, meta: { arg, requestId, requestStatus } }
// The fulfilled action's shape
// { type: 'user/fetch/fulfilled', payload: User, meta: { arg, requestId, requestStatus } }
// The rejected action's shape
// { type: 'user/fetch/rejected', payload: FetchUserError | undefined, error: { message }, meta: { arg, requestId, requestStatus, aborted, condition } }
The meta field of each action contains the arg passed to the thunk, a unique requestId, and a requestStatus string. The requestId is useful for tracking which request the action belongs to when multiple requests are in flight. The rejected action’s meta.aborted is true when the thunk was cancelled rather than failed, which allows the slice to distinguish between a real error and a cancellation .
The slice handles the three actions in extraReducers with the builder’s addCase.
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, error: null } as UserState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false
state.data = action.payload // User
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false
// action.payload is FetchUserError when rejectWithValue was used
state.error = action.payload?.message ?? action.error.message ?? 'Unknown'
})
},
})
The fulfilled handler receives the User payload, and the rejected handler receives the FetchUserError payload if rejectWithValue was used, or undefined if it was not. The fallback to action.error.message covers the case where the payload creator threw an error instead of returning a rejection value .
c. Dispatching the thunk and unwrapping the result
The thunk is dispatched like any other action. The dispatch function returns a promise that resolves with the fulfilled action and rejects with the rejected action.
import { useAppDispatch } from './hooks'
function UserProfile({ userId }: { userId: number }) {
const dispatch = useAppDispatch()
useEffect(() => {
// Dispatch the thunk; the promise resolves when the request settles
dispatch(fetchUser(userId))
}, [dispatch, userId])
// ...
}
The dispatch(fetchUser(userId)) call returns a promise. The promise resolves to the fulfilled action if the request succeeds, and rejects to the rejected action if it fails. In most cases, the component does not need to handle the promise; the slice’s extraReducers updates the state, and the component re-renders from the selectors. The promise is useful when the component needs to perform a side effect after the request completes, such as navigating to a detail page after a successful create.
The unwrap method on the returned promise resolves with the payload and rejects with the error. It is the recommended way to handle the result in a component.
async function handleSubmit() {
try {
const user = await dispatch(fetchUser(userId)).unwrap()
// user is typed as User
navigate(`/users/${user.id}`)
} catch (error) {
// error is the rejected payload (FetchUserError) or the Error
console.error('Failed to fetch user:', error)
}
}
The unwrap method throws the rejected payload when the thunk fails, so the catch block receives the FetchUserError if rejectWithValue was used, or the Error object if the payload creator threw. The try/catch is the correct pattern for handling the result in an event handler, because the failure is not a state change that the UI needs to render; it is a transient error that the handler can display or log .
The condition option can prevent the thunk from running when a condition is not met. It is evaluated before the payload creator, and if it returns false, the thunk is not dispatched and no action is generated.
export const fetchUser = createAsyncThunk<User, number>(
'user/fetch',
async (userId) => { /* ... */ },
{
condition: (userId, { getState }) => {
const { user } = getState() as { user: UserState }
// Skip the fetch if the user is already loaded
if (user.data?.id === userId) return false
},
}
)
The condition is useful for avoiding duplicate requests when the data is already in the store. It is evaluated before the pending action, so the slice does not enter the loading state if the condition returns false .
Complete Example Session
// ============================================
// PART 1: THE TYPES
// ============================================
interface Post {
id: number
title: string
body: string
}
interface PostsState {
items: Post[]
loading: boolean
error: string | null
}
// ============================================
// PART 2: THE FETCH THUNK
// ============================================
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
export const fetchPosts = createAsyncThunk<
Post[],
void,
{ rejectValue: string }
>('posts/fetchAll', async (_, { rejectWithValue, signal }) => {
const response = await fetch('/api/posts', { signal })
if (!response.ok) {
return rejectWithValue(`HTTP ${response.status}`)
}
return response.json()
})
// ============================================
// PART 3: THE CREATE THUNK
// ============================================
export const createPost = createAsyncThunk<
Post,
{ title: string; body: string },
{ rejectValue: string }
>('posts/create', async (newPost, { rejectWithValue, dispatch }) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
})
if (!response.ok) {
return rejectWithValue(`HTTP ${response.status}`)
}
const created = await response.json()
// Refresh the list after creating
dispatch(fetchPosts())
return created
})
// ============================================
// PART 4: THE SLICE WITH extraReducers
// ============================================
const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], loading: false, error: null } as PostsState,
reducers: {
clearError: (state) => {
state.error = null
},
},
extraReducers: (builder) => {
builder
// ============================================
// PART 5: FETCH LIFECYCLE
// ============================================
.addCase(fetchPosts.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = false
state.items = action.payload
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false
state.error = action.payload ?? action.error.message ?? 'Unknown'
})
// ============================================
// PART 6: CREATE LIFECYCLE
// ============================================
.addCase(createPost.fulfilled, (state, action) => {
state.items.push(action.payload)
})
.addCase(createPost.rejected, (state, action) => {
state.error = action.payload ?? 'Create failed'
})
},
})
export const { clearError } = postsSlice.actions
export default postsSlice.reducer
// ============================================
// PART 7: THE COMPONENT WITH unwrap
// ============================================
import { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from './hooks'
function Posts() {
const dispatch = useAppDispatch()
const { items, loading, error } = useAppSelector((state) => state.posts)
useEffect(() => {
dispatch(fetchPosts())
}, [dispatch])
if (loading) return <p>Loading...</p>
if (error) return <p>Error: {error}</p>
return (
<ul>
{items.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// ============================================
// PART 8: HANDLING THE RESULT
// ============================================
function CreatePostForm() {
const dispatch = useAppDispatch()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const post = await dispatch(createPost({ title: 'New', body: '...' })).unwrap()
console.log('Created post:', post.id)
} catch (error) {
console.error('Create failed:', error)
}
}
return <form onSubmit={handleSubmit}><button>Create</button></form>
}
// ============================================
// PART 9: THE CONDITION OPTION
// ============================================
export const fetchPostById = createAsyncThunk<
Post,
number,
{ state: { posts: PostsState } }
>('posts/fetchById', async (id) => {
const response = await fetch(`/api/posts/${id}`)
if (!response.ok) throw new Error('Not found')
return response.json()
}, {
condition: (id, { getState }) => {
const post = getState().posts.items.find((p) => p.id === id)
if (post) return false // Already loaded; skip the request
},
})
// ============================================
// PART 10: THE STORE
// ============================================
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({
reducer: {
posts: postsSlice.reducer,
},
})
The ten parts demonstrate the complete createAsyncThunk surface: the types, the fetch thunk with rejectWithValue, the create thunk with dispatch, the slice’s extraReducers, the lifecycle handlers, the unwrap pattern, the condition option, and the store. The critical distinction is between Parts 2 and 3: the fetch thunk uses rejectWithValue for a typed error, and the create thunk dispatches another thunk after success.
Quick Reference
createAsyncThunk Type Parameters
| Parameter | Purpose | Example |
|---|---|---|
Returned | Payload type | User |
ThunkArg | Argument type | number |
ThunkApiConfig | { state, dispatch, rejectValue, extra } | { rejectValue: string } |
thunkAPI Properties
| Property | Purpose |
|---|---|
dispatch | Dispatch other actions |
getState | Read the current state |
rejectWithValue | Return a typed rejection payload |
signal | AbortSignal for cancellation |
extra | Injected dependency |
Lifecycle Actions
| Action | When | Payload |
|---|---|---|
.pending | Request started | undefined |
.fulfilled | Request succeeded | Return type |
.rejected | Request failed | rejectValue or undefined |
Result Handling
| Method | Behavior |
|---|---|
dispatch(thunk()) | Returns a promise |
.unwrap() | Resolves payload or throws error |
condition | Prevents dispatch; no actions generated |
Best Practices
✅ Do This:
// Use rejectWithValue for typed errors
return rejectWithValue(`HTTP ${response.status}`) # ✅
// Pass the signal to fetch for cancellation
const response = await fetch(url, { signal }) # ✅
// Use unwrap to handle the result in a component
const user = await dispatch(fetchUser(id)).unwrap() # ✅
// Use condition to avoid duplicate requests
condition: (id, { getState }) => !getState().posts.items.find(...) # ✅
// Type the thunk's return and argument
createAsyncThunk<User, number>(...) # ✅
// Handle all three actions in extraReducers
.addCase(thunk.pending, ...).addCase(thunk.fulfilled, ...).addCase(thunk.rejected, ...) # ✅
❌ Don’t Do This:
// Use fetch without checking response.ok
return response.json() // 500 cached as success # ❌
// Forget to handle the rejected action
.addCase(thunk.fulfilled, ...) // No error state # ❌
// Use throw instead of rejectWithValue when a typed payload is needed
throw new Error('...') // action.payload is undefined # ❌
// Ignore the signal
const response = await fetch(url) // Cannot be cancelled # ❌
// Use the thunk without a type parameter
createAsyncThunk('fetch', async () => ...) // payload is any # ❌
// Dispatch the thunk during render
function Component() { dispatch(fetchUser(1)) } // Infinite loop # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Payload type is any | No type parameter | createAsyncThunk<Data, Arg>(...) |
rejected payload is undefined | Used throw instead of rejectWithValue | Return rejectWithValue(value) |
| Request not cancelled | signal not passed to fetch | Pass { signal } |
| Duplicate requests | No condition | Add condition |
| Infinite loop | Dispatched during render | Dispatch in useEffect or event handler |
| Error not shown | rejected action not handled | Add the addCase |
Real-World Examples
1. Basic thunk
const fetchUser = createAsyncThunk<User, number>('user/fetch', async (id) => {
const res = await fetch(`/api/users/${id}`)
return res.json()
})
2. Thunk with rejectWithValue
createAsyncThunk<User, number, { rejectValue: string }>('user/fetch', async (id, { rejectWithValue }) => {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) return rejectWithValue(`HTTP ${res.status}`)
return res.json()
})
3. Thunk with signal
async (id, { signal }) => { const res = await fetch(url, { signal }); return res.json() }
4. Thunk with getState
async (_, { getState }) => { const token = getState().auth.token; ... }
5. Thunk with dispatch
async (data, { dispatch }) => { dispatch(otherThunk()); return result }
6. Handling pending
.addCase(fetchUser.pending, (state) => { state.loading = true })
7. Handling fulfilled
.addCase(fetchUser.fulfilled, (state, action) => { state.data = action.payload })
8. Handling rejected
.addCase(fetchUser.rejected, (state, action) => { state.error = action.payload })
9. Unwrapping the result
const user = await dispatch(fetchUser(id)).unwrap()
10. Condition option
condition: (id, { getState }) => !getState().posts.items.find((p) => p.id === id)
Visual
The Thunk Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ createAsyncThunk DISPATCHES THREE ACTIONS │
│ │
│ dispatch(fetchUser(1)) │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ pending │ │
│ │ → extraReducers: state.loading = true │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ payload creator runs │ │
│ │ await fetch(...) │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ ▼ ▼ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ fulfilled │ │ rejected │ │
│ │ payload = User │ │ payload = rejectValue │ │
│ │ → state.data = payload │ │ → state.error = payload │ │
│ │ → state.loading = false │ │ → state.loading = false │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
rejectWithValue vs throw
┌──────────────────────────────────────────────────────────────┐
│ TWO WAYS TO FAIL, TWO PAYLOAD SHAPES │
│ │
│ rejectWithValue('HTTP 500'): │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ action = { │ │
│ │ type: 'user/fetch/rejected', │ │
│ │ payload: 'HTTP 500', ← typed via rejectValue │ │
│ │ error: { message: 'Rejected' }, │ │
│ │ meta: { aborted: false } │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ throw new Error('HTTP 500'): │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ action = { │ │
│ │ type: 'user/fetch/rejected', │ │
│ │ payload: undefined, ← no payload │ │
│ │ error: { message: 'HTTP 500' }, │ │
│ │ meta: { aborted: false } │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Use rejectWithValue when the slice needs a structured error.│
└──────────────────────────────────────────────────────────────┘
unwrap
┌──────────────────────────────────────────────────────────────┐
│ unwrap RESOLVES THE PAYLOAD OR THROWS THE ERROR │
│ │
│ Without unwrap: │
│ const action = await dispatch(fetchUser(1)) │
│ → action is the full action object │
│ → action.payload is the data (if fulfilled) │
│ → action.error is the error (if rejected) │
│ → no automatic throw │
│ │
│ With unwrap: │
│ try { │
│ const user = await dispatch(fetchUser(1)).unwrap() │
│ → user is the payload (typed as User) │
│ } catch (error) { │
│ → error is the rejected payload │
│ } │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Function | createAsyncThunk(typePrefix, payloadCreator, options?) |
| Return type | Thunk action creator with .pending, .fulfilled, .rejected |
| Payload creator arguments | (arg, thunkAPI) |
thunkAPI | dispatch, getState, rejectWithValue, signal, extra |
rejectWithValue | Returns a typed rejection payload |
signal | AbortSignal for fetch cancellation |
condition | Prevents the thunk from running |
unwrap | Resolves payload or throws error |
| Lifecycle handling | extraReducers with addCase |
Key takeaways:
createAsyncThunkgenerates the three lifecycle actions automatically. Thepending,fulfilled, andrejectedactions are dispatched as the request progresses, and the slice handles them inextraReducerswithaddCase.- The payload creator must check
response.ok. Afetchresponse with a 4xx or 5xx status resolves successfully unless the function checks and throws. TherejectWithValuehelper returns a typed payload for therejectedaction . rejectWithValueis the correct way to return a structured error. Therejectedaction’spayloadcontains the value passed torejectWithValue, and the slice’srejectedhandler receives it with the correct type. A plainthrowproduces a rejected action withpayload: undefined.- The
signalallows the request to be cancelled. The payload creator passes it tofetch, and the request is aborted when the thunk is cancelled. Therejectedaction’smeta.abortedistruefor cancellations, which the slice can distinguish from real failures . - The
conditionoption prevents unnecessary requests. It is evaluated before thependingaction, and if it returnsfalse, the thunk is not dispatched and no action is generated. It is useful for skipping a fetch when the data is already in the store . unwrapis the pattern for handling the result in a component. It resolves the payload or throws the error, which allows the component to usetry/catchand perform side effects such as navigation after a successful create .
Remember: createAsyncThunk is the async action creator. It performs the request, dispatches the lifecycle actions, and returns the result. The payload creator receives the argument and the thunkAPI, checks response.ok, and uses rejectWithValue for structured errors. The slice handles the three actions in extraReducers. The component dispatches the thunk and uses unwrap to handle the result. The condition option avoids duplicate requests, and the signal allows cancellation. The result is consistent async handling across every feature.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!