| | |

Redux Toolkit 09 ⚛️ Async Thunks — Handling asynchronous API requests with createAsyncThunk

⏱️ Estimated read: 16 min | |

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

ParameterPurposeExample
ReturnedPayload typeUser
ThunkArgArgument typenumber
ThunkApiConfig{ state, dispatch, rejectValue, extra }{ rejectValue: string }

thunkAPI Properties

PropertyPurpose
dispatchDispatch other actions
getStateRead the current state
rejectWithValueReturn a typed rejection payload
signalAbortSignal for cancellation
extraInjected dependency

Lifecycle Actions

ActionWhenPayload
.pendingRequest startedundefined
.fulfilledRequest succeededReturn type
.rejectedRequest failedrejectValue or undefined

Result Handling

MethodBehavior
dispatch(thunk())Returns a promise
.unwrap()Resolves payload or throws error
conditionPrevents 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

PitfallWhy It HappensFix
Payload type is anyNo type parametercreateAsyncThunk<Data, Arg>(...)
rejected payload is undefinedUsed throw instead of rejectWithValueReturn rejectWithValue(value)
Request not cancelledsignal not passed to fetchPass { signal }
Duplicate requestsNo conditionAdd condition
Infinite loopDispatched during renderDispatch in useEffect or event handler
Error not shownrejected action not handledAdd 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

ItemValue
FunctioncreateAsyncThunk(typePrefix, payloadCreator, options?)
Return typeThunk action creator with .pending, .fulfilled, .rejected
Payload creator arguments(arg, thunkAPI)
thunkAPIdispatch, getState, rejectWithValue, signal, extra
rejectWithValueReturns a typed rejection payload
signalAbortSignal for fetch cancellation
conditionPrevents the thunk from running
unwrapResolves payload or throws error
Lifecycle handlingextraReducers with addCase

Key takeaways:

  • createAsyncThunk generates the three lifecycle actions automatically. The pending, fulfilled, and rejected actions are dispatched as the request progresses, and the slice handles them in extraReducers with addCase .
  • The payload creator must check response.ok. A fetch response with a 4xx or 5xx status resolves successfully unless the function checks and throws. The rejectWithValue helper returns a typed payload for the rejected action .
  • rejectWithValue is the correct way to return a structured error. The rejected action’s payload contains the value passed to rejectWithValue, and the slice’s rejected handler receives it with the correct type. A plain throw produces a rejected action with payload: undefined .
  • The signal allows the request to be cancelled. The payload creator passes it to fetch, and the request is aborted when the thunk is cancelled. The rejected action’s meta.aborted is true for cancellations, which the slice can distinguish from real failures .
  • The condition option prevents unnecessary requests. It is evaluated before the pending action, and if it returns false, 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 .
  • unwrap is the pattern for handling the result in a component. It resolves the payload or throws the error, which allows the component to use try/catch and 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.


Was this helpful?


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!