Redux Toolkit's createAsyncThunk: Cutting Async Boilerplate Without Losing Control
Redux Toolkit's createAsyncThunk eliminates the three-action boilerplate for async flows while preserving explicit lifecycle actions, DevTools visibility, and serialization safety. A concise fetch-user example shows the pattern end-to-end.
10 Oct 2025, 09:38 UTC

The Problem: Async Logic Still Feels Like Ceremony
Redux solved predictable state updates, but asynchronous flows—API calls, timeouts, websockets—always required extra scaffolding. You define three action types (request, success, failure), write action creators for each, wire them into a thunk or saga, and then handle every case in reducers. Multiply that by a dozen endpoints and the boilerplate drowns the actual logic.
createAsyncThunk from Redux Toolkit (RTK) compresses that pattern into a single call while keeping the explicit action lifecycle Redux developers rely on. It generates pending, fulfilled, and rejected action types automatically, integrates with configureStore for serialization checks, and surfaces everything in Redux DevTools without extra wiring.
How It Works Under the Hood
When you call createAsyncThunk('users/fetch', async () => { ... }), RTK creates a thunk action creator that, when dispatched, immediately fires a users/fetch/pending action. Your payload creator runs; if it resolves, a users/fetch/fulfilled action carries the return value. If it throws or returns a rejected promise, a users/fetch/rejected action carries the error (or a serialized RejectWithValue payload).
These actions are plain objects, so they flow through middleware exactly like hand-written actions. The difference is you never write the type strings or action creators yourself. RTK also attaches a requestId to each dispatch, enabling request cancellation patterns if you later adopt abortSignal.
Worked Example: Fetching a User Profile
Assume a REST endpoint GET /api/users/:id returning { id: number, name: string, email: string }. Here’s the minimal slice:
// features/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// 1. Define the thunk
export const fetchUser = createAsyncThunk(
'users/fetch',
async (userId, { rejectWithValue }) => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
// Return a typed error payload instead of throwing
return rejectWithValue(await res.json());
}
return res.json(); // becomes action.payload on fulfilled
}
);
// 2. Handle the three lifecycle actions in the slice
const usersSlice = createSlice({
name: 'users',
initialState: { entities: {}, loading: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state, action) => {
state.loading = 'pending';
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = 'succeeded';
state.entities[action.payload.id] = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = 'failed';
state.error = action.payload || action.error.message;
});
}
});
export default usersSlice.reducer;
In a React component you dispatch the thunk like any action:
// components/UserProfile.jsx
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchUser } from '../features/usersSlice';
export function UserProfile({ userId }) {
const dispatch = useDispatch();
const { entities, loading, error } = useSelector((state) => state.users);
const user = entities[userId];
useEffect(() => {
if (userId) dispatch(fetchUser(userId));
}, [dispatch, userId]);
if (loading === 'pending') return Loading…
;
if (loading === 'failed') return Error: {error}
;
if (!user) return No data
;
return (
Name{user.name}
Email{user.email}
);
}
Open Redux DevTools after the component mounts. You’ll see three sequential actions: users/fetch/pending, then either users/fetch/fulfilled with the user object or users/fetch/rejected with the error payload. No extra logging code required.
Trade-offs and Guardrails
The convenience comes with two non-negotiable constraints:
- Serializability: Every value that reaches the store—thunk arguments, fulfilled payloads, rejected payloads—must be serializable. Functions, class instances, DOM nodes, or
Dateobjects will trigger thenon-serializable-valuewarning in development. If you need to pass a callback, keep it outside the thunk (e.g., in a component) and only dispatch plain data. - No direct state mutation outside Immer:
createSliceuses Immer, sostate.entities[id] = payloadis safe. But inside the thunk’s payload creator you must not mutate any external state; treat the function as pure aside from the async call.
A common pitfall is dispatching the same thunk repeatedly from a render cycle. The example above guards with useEffect and a dependency on userId. If you dispatch from an event handler, ensure the handler isn’t recreated every render (wrap with useCallback or define outside the component).
Verify It Works in Your Project
- Install the toolkit:
npm i @reduxjs/toolkit react-redux(oryarn add). - Add the slice and thunk to your store’s reducer map via
configureStore({ reducer: { users: usersReducer } }). - Run the app, open Redux DevTools (browser extension), and trigger the fetch.
- Confirm the action sequence
pending → fulfilled(orrejected) appears and the state tree updates exactly as the reducer specifies. - Intentionally return a non-serializable value (e.g.,
return new Date()) from the thunk and watch the console warning—this validates the serialization guard is active.
If the actions appear and state matches, the integration is solid. From there you can layer on caching, request deduplication, or optimistic updates without rewriting the async skeleton.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.