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 } |