Learn Redux - Async with createAsyncThunk
Episode 6 of 23

Learn Redux - Async with createAsyncThunk

This episode teaches createAsyncThunk for fetching API data and storing the results in a reducer. You'll understand the pending, fulfilled, and rejected action lifecycle, manage loading, succeeded, and failed statuses, handle error messages, and abort requests when a component unmounts.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Everything we've done so far is synchronous. Real applications always deal with data from the server: fetching users, posting forms, uploading files — all of it async. Redux Toolkit provides createAsyncThunk to handle this cleanly.

Episode 6 covers how to create an async thunk, the three automatically generated lifecycle actions, the loading, succeeded, and failed status pattern, error handling, and the abort mechanism when a component is no longer active.

Getting to Know createAsyncThunk

Creating Your First Async Thunk

createAsyncThunk(typePrefix, payloadCreator) accepts two arguments: an action name prefix and an async function that returns the result.

JSA thunk for fetching posts
import { createAsyncThunk } from "@reduxjs/toolkit"
 
export const fetchPosts = createAsyncThunk("posts/fetchPosts", async () => {
  const res = await fetch("/api/posts")
  const data = await res.json()
  return data
})

"posts/fetchPosts" is the prefix; from it, RTK generates three action types: posts/fetchPosts/pending, posts/fetchPosts/fulfilled, and posts/fetchPosts/rejected. createAsyncThunk creates a thunk you can dispatch like a normal action.

Using the Thunk in a Component

JSDispatch a thunk in a component
const dispatch = useDispatch()
 
useEffect(() => {
  dispatch(fetchPosts())
}, [dispatch])

dispatch(fetchPosts()) runs the payloadCreator, then dispatches a pending, fulfilled, or rejected action depending on the outcome — without handling any promises manually in the component.

Lifecycle Actions

The Three Generated Actions

RTK dispatches three actions in sequence:

Thunk lifecycle order
dispatch(thunk) -> pending -> (success) -> fulfilled
                              -> (error)  -> rejected
  • pending: dispatched when the thunk starts running — ideal for turning on a loading indicator.
  • fulfilled: dispatched when the payloadCreator succeeds, carrying the result as action.payload.
  • rejected: dispatched when an error occurs, carrying action.error.

Note that createAsyncThunk only throws an error if the payloadCreator throws an exception or its promise rejects. Errors from HTTP responses like a status 500 don't automatically reject — the response still resolves as long as the fetch succeeded.

Managing Status and Errors

The Per-Slice Status Pattern

The recommended standard pattern is to store status and error in the slice state, then handle them in extraReducers:

JSSlice with async status
import { createSlice } from "@reduxjs/toolkit"
import { fetchPosts } from "./fetchPosts"
 
const postsSlice = createSlice({
  name: "posts",
  initialState: { items: [], status: "idle", error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.status = "loading"
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = "succeeded"
        state.items = action.payload
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.status = "failed"
        state.error = action.error.message
      })
  },
})

fetchPosts.pending, .fulfilled, and .rejected are generated action creators — usable directly in builder.addCase. state.error = action.error.message stores the error message for display.

Displaying Status in the UI

JSRender based on status
const { items, status, error } = useSelector((state) => state.posts)
 
if (status === "loading") return <p>Loading...</p>
if (status === "failed") return <p>Error: {error}</p>
return <ul>{items.map((p) => <li key={p.id}>{p.title}</li>)}</ul>

The status === "loading", then "failed", then rendering data pattern is the most common async UI cycle. The status stays in the store so it can be shared across components.

Handling Errors and Aborts

Passing Errors Through rejectWithValue

For business-side errors — such as a 400 validation from the server — use rejectWithValue so a custom message reaches the reducer:

JSReject with a custom message
export const fetchPosts = createAsyncThunk(
  "posts/fetchPosts",
  async (_, { rejectWithValue }) => {
    const res = await fetch("/api/posts")
    if (!res.ok) {
      return rejectWithValue("Failed to load data")
    }
    return res.json()
  },
)

rejectWithValue dispatches a rejected action with a payload message you can read as action.payload in the reducer. createAsyncThunk provides a second argument as an object with rejectWithValue, getState, and dispatch.

Aborting Requests When a Component Unmounts

The thunk receives a signal from a built-in AbortController. fetch supports this signal, so the request can be cancelled:

JSAborting a request that's no longer needed
export const fetchPosts = createAsyncThunk(
  "posts/fetchPosts",
  async (_, { signal }) => {
    const res = await fetch("/api/posts", { signal })
    return res.json()
  },
)

When a component unmounts, React Redux aborts unfinished dispatches through its internal mechanism. Wiring signal into fetch ensures the HTTP request is truly cancelled and doesn't waste resources.

Conclusion

Episode 6 opens the async world of Redux: createAsyncThunk produces a dispatchable thunk, three lifecycle actions are generated automatically, the status pattern stays clean, errors are handled, and requests are aborted gracefully.

Key takeaways:

  • createAsyncThunk(prefix, payloadCreator) creates an async action.
  • Lifecycle actions: pending, fulfilled, and rejected.
  • Store idle, loading, succeeded, and failed statuses in the slice.
  • action.error.message for the default error message; rejectWithValue for custom messages.
  • state.error = action.error.message is the standard error storage pattern.
  • Pass signal to fetch so requests can be aborted.

In the next episode, episode 7, you'll strengthen the foundation with TypeScript and typing the store — defining RootState and AppDispatch, creating the typed hooks useAppSelector and useAppDispatch, and giving createAsyncThunk and createSelector proper generics.

Learn Redux - Async with createAsyncThunk | Learn Redux