Async Thunks — Data Fetching with createAsyncThunk

Redux reducers must be pure and synchronous — they cannot make API calls or handle Promises. Thunks are Redux middleware functions that let you write async logic before dispatching actions. Redux Toolkit’s createAsyncThunk generates a thunk that automatically dispatches pending, fulfilled, and rejected actions as the async operation progresses. The slice’s extraReducers handles these three states, updating isLoading, data, and error without any boilerplate.

createAsyncThunk for API Calls

// src/store/postsSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { postsApi } from "@/services/posts";

// ── Async thunks ──────────────────────────────────────────────────────────────
export const fetchPosts = createAsyncThunk(
    "posts/fetchPosts",             // action type prefix
    async ({ page = 1, tag = null, pageSize = 10 } = {}, { rejectWithValue }) => {
        try {
            return await postsApi.list({ page, tag, page_size: pageSize });
        } catch (err) {
            // rejectWithValue lets us pass a custom error to the rejected action
            return rejectWithValue(err.response?.data?.detail ?? err.message);
        }
    }
);

export const fetchPostById = createAsyncThunk(
    "posts/fetchById",
    async (postId, { rejectWithValue }) => {
        try {
            return await postsApi.getById(postId);
        } catch (err) {
            return rejectWithValue(err.response?.data?.detail ?? "Post not found");
        }
    }
);

export const createPost = createAsyncThunk(
    "posts/create",
    async (postData, { rejectWithValue }) => {
        try {
            return await postsApi.create(postData);
        } catch (err) {
            return rejectWithValue(err.response?.data ?? err.message);
        }
    }
);

// ── Slice with extraReducers for async states ─────────────────────────────────
const postsSlice = createSlice({
    name: "posts",
    initialState: {
        items:       [],
        total:       0,
        currentPost: null,
        isLoading:   false,
        isFetching:  false,   // background refetch in progress
        error:       null,
    },
    reducers: {
        clearCurrentPost(state) { state.currentPost = null; },
    },
    extraReducers: (builder) => {
        // fetchPosts
        builder
            .addCase(fetchPosts.pending, (state) => {
                state.isLoading = true;
                state.error     = null;
            })
            .addCase(fetchPosts.fulfilled, (state, action) => {
                state.isLoading = false;
                state.items     = action.payload.items;
                state.total     = action.payload.total;
            })
            .addCase(fetchPosts.rejected, (state, action) => {
                state.isLoading = false;
                state.error     = action.payload ?? "Failed to load posts";
            });

        // fetchPostById
        builder
            .addCase(fetchPostById.pending, (state) => {
                state.isFetching   = true;
                state.currentPost  = null;
                state.error        = null;
            })
            .addCase(fetchPostById.fulfilled, (state, action) => {
                state.isFetching  = false;
                state.currentPost = action.payload;
            })
            .addCase(fetchPostById.rejected, (state, action) => {
                state.isFetching = false;
                state.error      = action.payload;
            });

        // createPost — prepend to list on success
        builder
            .addCase(createPost.fulfilled, (state, action) => {
                state.items.unshift(action.payload);
                state.total += 1;
            });
    },
});

export const { clearCurrentPost } = postsSlice.actions;
export default postsSlice.reducer;
Note: createAsyncThunk generates three action types automatically: "posts/fetchPosts/pending", "posts/fetchPosts/fulfilled", and "posts/fetchPosts/rejected". You handle these in extraReducers (not reducers) because they are generated externally and not part of the slice’s own action creators. The builder.addCase(thunk.pending, handler) syntax is type-safe and the recommended way to add these handlers.
Tip: Use rejectWithValue in your thunk’s catch block to pass a meaningful error to the rejected case handler. Without it, the rejected action’s payload is undefined and you cannot show specific error messages. rejectWithValue(err.response?.data?.detail ?? err.message) extracts FastAPI’s validation error message or falls back to the network error message, giving components the information they need to display a helpful error state.
Warning: Avoid dispatching the same thunk in multiple components simultaneously — if five components all call dispatch(fetchPosts()) on mount, you make five identical API requests. Use a “guard” in the thunk: check if data is already loading before fetching (const state = getState(); if (state.posts.isLoading) return;), or use RTK Query (Lesson 4) which handles request deduplication automatically.

Dispatching Thunks from Components

import { useEffect }              from "react";
import { useDispatch, useSelector } from "react-redux";
import { fetchPosts, selectPosts, selectPostsLoading } from "@/store/postsSlice";

function PostFeed({ page, tag }) {
    const dispatch  = useDispatch();
    const posts     = useSelector(selectPosts);
    const isLoading = useSelector(selectPostsLoading);
    const error     = useSelector(s => s.posts.error);

    useEffect(() => {
        dispatch(fetchPosts({ page, tag }));
    }, [dispatch, page, tag]);

    if (isLoading) return <PostListSkeleton />;
    if (error)     return <ErrorMessage message={error} />;
    return <PostList posts={posts} />;
}

function PostDetailPage({ postId }) {
    const dispatch     = useDispatch();
    const currentPost  = useSelector(s => s.posts.currentPost);
    const isFetching   = useSelector(s => s.posts.isFetching);

    useEffect(() => {
        dispatch(fetchPostById(postId));
        return () => { dispatch(clearCurrentPost()); };
    }, [dispatch, postId]);

    if (isFetching)   return <PostDetailSkeleton />;
    if (!currentPost) return <NotFoundPage />;
    return <PostDetail post={currentPost} />;
}

Common Mistakes

Mistake 1 — Putting side effects in reducers (breaks purity)

❌ Wrong — API call inside a reducer:

reducers: {
    fetchPosts(state) {
        fetch("/api/posts").then(r => r.json()).then(data => {
            state.items = data;   // reducer must be synchronous and pure!
        });
    }
}

✅ Correct — use createAsyncThunk for async work, then handle states in extraReducers.

Mistake 2 — Forgetting rejectWithValue (undefined error in rejected case)

❌ Wrong — error is undefined, cannot show error message:

} catch (err) {
    throw err;   // rejected action.payload = undefined
}

✅ Correct:

} catch (err) {
    return rejectWithValue(err.response?.data?.detail ?? err.message);   // ✓
}

Quick Reference

Task Code
Create async thunk createAsyncThunk("slice/action", async (arg, { rejectWithValue }) => ...)
Handle states builder.addCase(thunk.pending/fulfilled/rejected, handler)
Reject with message return rejectWithValue(errorMessage)
Dispatch thunk dispatch(fetchPosts({ page, tag }))
Get state in thunk Second arg: { getState, dispatch, rejectWithValue }

🧠 Test Yourself

A fetchPosts thunk is dispatched. The API returns a 401 response. Without rejectWithValue, what is action.payload in the rejected handler?